17 июня 2016 в 22:02

Особенности кэширования компонентов в Unity3D tutorial

Большинство unity-разработчиков знают, что не стоит злоупотреблять дорогими для производительности операциями, такими как, например, получение компонентов. Для этого стоит использовать кэширование. Но и для такой простой оптимизации можно найти несколько различных подходов.
В этой статье будут рассмотрены разные варианты кэширования, их неочевидные особенности и производительность.




Стоит отметить, что говорить мы будем в основном о “внутреннем” кэшировании, то есть получении тех компонентов, которые есть на текущем объекте для его внутренних нужд. Для начала откажемся от прямого назначения зависимостей в инспекторе — это неудобно в использовании, засоряет настройки скрипта и может привести к битым ссылкам при вылете редактора. Поэтому будем использовать GetComponent().

Базовые знания о компонентах и простой пример кэширования
В Unity3D каждый объект на игровой сцене — это контейнер (GameObject) для различных компонентов (Component), которые могут быть как встроенными в движок (Transform, AudioSource и т.д.), так и пользовательскими скриптами (MonoBehaviour).
Компонент может быть назначен напрямую в редакторе, а для получения компонента из контейнера в скрипте используется метод GetComponent().

Если компонент требуется использовать не единожды, традиционный подход — объявить в скрипте, где он будет использоваться, переменную для него, взять нужный компонент один раз и в дальнейшем использовать полученное значение. Пример:

public class Example : MonoBehaviour {
	Rigidbody _rigidbody;

	void Start () {
		_rigidbody = GetComponent<Rigidbody>();
	}
	
	void Update () {
		_rigidbody.AddForce(Vector3.up * Time.deltaTime);
	}
}




Кэширование при инициализации актуально также и для свойств, предоставляемых GameObject по умолчанию, таких как .transform, .render и других. Для доступа к ним явное кэширование все равно будет быстрее (да и большая часть из них в Unity 5 помечена как deprecated, так что хорошим тоном будет отказаться от их использования).

Стоит отметить, что самый очевидный метод кэширования (прямое получение компонентов) изначально является наиболее производительным в общем случае и другие варианты лишь его используют и предоставляют более простой доступ к компонентам, избавляя от необходимости писать однотипный код каждый раз либо получать компоненты по запросу.

Немного о том, что кэширование - не главное
Также отмечу, что есть и намного более дорогие операции (такие как создание и удаление объектов на сцене) и кэшировать компоненты, не уделяя внимания им, будет пустой тратой времени. Например, в вашей игре есть пулемет, который стреляет пулями, каждая из которых является отдельным объектом (что само по себе неправильно, но это же сферический пример). Вы создаете объект, кэшируете в нем Collider, ParticleSystem и еще кучу всего, но пуля улетает в небо и убивается через 3 секунды, а эти компоненты не используются вообще.

Для того, чтобы этого избежать, используйте пул объектов, об этом есть статьи на Хабре (1, 2) и существуют готовые решения. В таком случае вы не будете постоянно создавать и удалять объекты и раз за разом кэшировать их, они будут переиспользоваться, а кэширование произойдет лишь единожды.


Производительность всех рассмотренных вариантов кэширования будет отображена в сводной диаграмме.

Основы



У метода GetComponent есть два варианта использования: шаблонный GetComponent() и обычный GetComponent(type), требующий дополнительного приведения (comp as T). В сводной диаграмме по производительности будут рассмотрены оба этих варианта, но стоит учесть, что шаблонный метод проще в применении. Также существует вариант получения списка компонентов GetComponents с аналогичными вариантами, они также будут проверены. В диаграммах время выполнения GetComponent на каждой платформе принято за 100% для нивелирования особенностей оборудования, а также есть интерактивные версии для большего удобства.

Использование свойств



Для кэширования можно использовать свойства. Плюс этого метода — кэширование произойдет только тогда, когда мы обратимся к свойству, и его не будет тогда, когда это свойство используется. Минус заключается в том, что в данном случае мы пишем больше однотипного кода.

Самый простой вариант:

Transform _transform = null;
public Transform CachedTransform {
	get {
		if( !_transform ) {
			_transform = GetComponent<Transform>();
		}
	return _transform;
	}
}


Этот вариант благодаря проверке на отсутствие компонента обладает проблемами с производительностью.

!component, что это?
Здесь нужно учитывать, что в Unity3D используется кастомный оператор сравнения, поэтому когда мы безопасно проверяем, закэшировался ли компонент ( if ( !component )), на самом деле движок обращается в native-код, что является ресурсозатратным, более подробно можно прочитать в этой статье.


Есть два варианта решения этой проблемы:
Использовать дополнительный флаг, указывающий, производилось ли кэширование:

Transform _transform = null;
bool _transformCached = false;
public Transform CachedTransform {
	get {
		if( !_transformCached ) {
			_transformCached = true;
			_transform = GetComponent<Transform>();
		}
	return _transform;
	}
}


Явно приводить компонент к object:

Transform _transform = null;
public Transform CachedTransform {
	get {
		if( (object)_transform == null ) {
			_transform = GetComponent<Transform>();
		}
	return _transform;
	}
}


Но нужно учитывать, что этот вариант безопасен только в том случае, когда компоненты объекта не удаляются (что обычно происходит нечасто).

Почему в Unity можно обратиться к уничтоженному объекту?
Небольшая выдержка из статьи по ссылке выше:
When you get a c# object of type “GameObject”, it contains almost nothing. this is because Unity is a C/C++ engine. All the actual information about this GameObject (its name, the list of components it has, its HideFlags, etc) lives in the c++ side. The only thing that the c# object has is a pointer to the native object. We call these c# objects “wrapper objects”. The lifetime of these c++ objects like GameObject and everything else that derives from UnityEngine.Object is explicitly managed. These objects get destroyed when you load a new scene. Or when you call Object.Destroy(myObject); on them. Lifetime of c# objects gets managed the c# way, with a garbage collector. This means that it’s possible to have a c# wrapper object that still exists, that wraps a c++ object that has already been destroyed. If you compare this object to null, our custom == operator will return “true” in this case, even though the actual c# variable is in reality not really null.


Проблема здесь в том, что приведение к object хоть и позволяет обойти дорогой вызов native-кода, но при этом лишает нас кастомного оператора проверки существования объекта. Его C# обертка все еще может существовать, когда на самом деле объект уже уничтожен.


Наследование



Для упрощения задачи можно наследовать свои классы от компонента, кэширующего самые используемые свойства, но этот вариант неуниверсален (требует создания и модификации всех необходимых свойств) и не позволяет наследоваться от других компонентов, если это потребуется (в C# нет множественного наследования).

Первая проблема может быть решена использованием шаблонов:

public class InnerCache : MonoBehaviour {
	Dictionary<Type, Component> cache = new Dictionary<Type, Component>();

	public T Get<T>() where T : Component {
		var type = typeof(T);
		Component item = null;
		if (!cache.TryGetValue(type, out item)) {
			item = GetComponent<T>();
			cache.Add(type, item);
		}
		return item as T;
	}
}


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

Статическое кэширование



Есть вариант использования такой особенности C#, как расширение. Она позволяет добавлять свои методы в уже существующие классы без их модификации и наследования. Это делается следующим образом:

public static class ExternalCache {
	static Dictionary<GameObject, TestComponent> test = new Dictionary<GameObject, TestComponent>();

	public static TestComponent GetCachedTestComponent(this GameObject owner) {
		TestComponent item = null;
		if (!test.TryGetValue(owner, out item)) {
			item = owner.GetComponent<TestComponent>();
                	test.Add(owner, item);
            	}
            	return item;
        }
 }


После этого в любом скрипте можно получить этот компонент:

gameObject.GetCachedTestComponent();


Но этот вариант снова требует задания всех необходимых компонентов заранее. Можно решить это с помощью шаблонов:

public static class ExternalCache {
	static Dictionary<GameObject, Dictionary<Type, Component>> cache = new Dictionary<GameObject, Dictionary<Type, Component>>();

        public static T GetCachedComponent<T>(this GameObject owner) where T : Component {
        	var type = typeof(T);
		Dictionary<Type, Component> container = null;
		if (!cache.TryGetValue(owner, out container)) {
			container = new Dictionary<Type, Component>();
			cache.Add(owner, container);
            	}
		Component item = null;
		if (!container.TryGetValue(type, out item)) {
			item = owner.GetComponent<T>();
			container.Add(type, item);
            	}
		return item as T;
        }
}


Минус этих вариантов — нужно следить за мертвыми ссылками. Если не очищать кэш (например, при загрузке сцены), то его объем будет только расти и засорять память ссылками на уже уничтоженные объекты.

Сравнение производительности



image
Интерактивный вариант

Как мы видим, серебряной пули не нашлось и самый оптимальный вариант кэширования — получать компоненты напрямую при инициализации. Обходные пути не оптимальны, за исключением свойств, которые требуют написания дополнительного кода.

Использование атрибутов



Атрибуты позволяют добавлять мета-информацию для элементов кода, таких как, например, члены класса. Сами по себе атрибуты не выполняются, их необходимо использовать при помощи рефлексии, которая является достаточно дорогой операцией.

Мы можем объявить свой собственный атрибут для кэширования:

[AttributeUsage(AttributeTargets.Field)]
public class CachedAttribute : Attribute {

}


И использовать его для полей своих классов:

[Cached]
public TestComponent Test;


Но пока что это нам ничего не даст, данная информация никак не используется.

Наследование



Мы можем создать свой класс, который будет получать члены класса с данным атрибутом и явно получать их при инициализации:

public class AttributeCacheInherit : MonoBehaviour {

	protected virtual void Awake () {
        	CacheAll();
	}

	void CacheAll() {
        	var type = GetType();
        	CacheFields(GetFieldsToCache(type));
	}

    	List<FieldInfo> GetFieldsToCache(Type type) {
        	var fields = new List<FieldInfo>();
        	foreach (var field in type.GetFields()) {
            		foreach (var a in field.GetCustomAttributes(false)) {
                		if (a is CachedAttribute) {
                    			fields.Add(field);
                		}
            		}
        	}
        	return fields;
    	}

	void CacheFields(List<FieldInfo> fields) {
        	var iter = fields.GetEnumerator();
        	while (iter.MoveNext()) {
            		var type = iter.Current.FieldType;
            		iter.Current.SetValue(this, GetComponent(type));
        	}
	}
}


Если мы создадим наследника этого компонента, то сможем помечать его члены атрибутом [Cached], тем самым не заботясь о их явном кэшировании.
Но проблема с производительностью и необходимость наследования нивелирует удобство данного метода.

Статический кэш типов



Список членов класса не меняется при выполнении кода, поэтому мы можем получить его один раз, сохранить его для данного типа и использовать в дальнейшем, почти не прибегая к дорогой рефлексии. Для этого нам потребуется статический класс, хранящий результаты анализа типов.

Кэширование типов
public static class CacheHelper {
	static Dictionary<Type, List<FieldInfo>> cachedTypes = new Dictionary<Type, List<FieldInfo>>();

	public static void CacheAll(MonoBehaviour instance, bool internalCache = true) {
		var type = instance.GetType();
		if ( internalCache ) {
			List<FieldInfo> fields = null;
			if ( !cachedTypes.TryGetValue(type, out fields) ) {
				fields = GetFieldsToCache(type);
				cachedTypes[type] = fields;
			}
			CacheFields(instance, fields);
		} else {
			CacheFields(instance, GetFieldsToCache(type));
		}
	}

	static List<FieldInfo> GetFieldsToCache(Type type) {
		var fields = new List<FieldInfo>();
		foreach ( var field in type.GetFields() ) {
			foreach ( var a in field.GetCustomAttributes(false) ) {
				if ( a is CachedAttribute ) {
					fields.Add(field);
				}
			}
		}
		return fields;
	}

	static void CacheFields(MonoBehaviour instance, List<FieldInfo> fields) {
		var iter = fields.GetEnumerator();
		while(iter.MoveNext()) {
			var type = iter.Current.FieldType;
			iter.Current.SetValue(instance, instance.GetComponent(type));
		}
	}
}



И теперь для кэширования в каком-либо скрипте мы используем обращение к нему:

void Awake() {
	CacheHelper.CacheAll(this);
}


После этого все члены класса, помеченные [Cached] будут получены с помощью GetComponent.

Эффективность кэширования с помощью аттрибутов



Сравним производительность для вариантов с 1 или 5 кэшируемыми компонентами:

image
Интерактивный вариант

image
Интерактивный вариант

Как можно видеть, этот метод уступает по производительности прямому получению компонентов (разрыв немного снижается с ростом их количества), но имеет ряд особенностей:
  • Критичное снижение производительности происходит только при инициализации первого экземпляра класса
  • Инициализация последующих экземпляров этого класса происходит значительно быстрее, но не так быстро, как прямое кэширование
  • Производительность получения компонентов после инициализации идентична получению члена класса и выше, чем у GetComponent и различных вариантов со свойствами
  • Но при этом инициализируются все члены класса, независимо от того, будут ли они использоваться в дальнейшем


Шаг назад или использование редактора



Уже когда я заканчивал эту статью, мне подсказали одно интересное решение для кэширования. Так ли необходимо в нашем случае сохранять компоненты именно в запущенном состоянии приложения? Вовсе нет, мы это делаем только единожды для каждого экземпляра, соответственно функционально это ничем не отличается от назначения их в редакторе до запуска приложения. А все, что можно сделать в редакторе, можно автоматизировать.

Так появилась идея кэшировать зависимости скриптов с помощью отдельной опции в меню, которая подготавливает экземпляры на сцене к дальнейшему использованию.

Последняя на сегодня простыня кода
using UnityEngine;
using UnityEditor;
using System;
using System.Reflection;
using System.Collections;
using System.Collections.Generic;

namespace UnityCache {
	public static class PreCacheEditor {
		public static bool WriteToLog = true;

		[MenuItem("UnityCache/PreCache")]
		public static void PreCache() {
			var items = GameObject.FindObjectsOfType<MonoBehaviour>();
			foreach(var item in items) {
				if(PreCacheAll(item)) {
					EditorUtility.SetDirty(item);
					if(WriteToLog) {
						Debug.LogFormat("PreCached: {0} [{1}]", item.name, item.GetType());
					}
				}
			}
		}
			
		static bool PreCacheAll(MonoBehaviour instance) {
			var type = instance.GetType();
			return CacheFields(instance, GetFieldsToCache(type)); 
		}

		static List<FieldInfo> GetFieldsToCache(Type type) {
			var fields = new List<FieldInfo>();
			foreach (var field in type.GetFields()) {
				foreach (var a in field.GetCustomAttributes(false)) {
					if (a is PreCachedAttribute) {
						fields.Add(field);
					}
				}
			}
			return fields;
		}

		static bool CacheFields(MonoBehaviour instance, List<FieldInfo> fields) {
			bool cached = false;
			UnityEditor.SerializedObject serObj = null;
			var iter = fields.GetEnumerator();
			while (iter.MoveNext()) {
				if(serObj == null) {
					serObj = new UnityEditor.SerializedObject(instance);
					cached = true;
				}
				var type = iter.Current.FieldType;
				var name = iter.Current.Name;

				var property = serObj.FindProperty(name);
				property.objectReferenceValue = instance.GetComponent(type);
				Debug.Log(property.objectReferenceValue);
			}
			if(cached) {
				serObj.ApplyModifiedProperties();
			}
			return cached;
		}
	}
}



У этого метода есть свои особенности:
  • Он не требует ресурсов на явную инициализацию
  • Объекты подготавливаются явно (перекомпиляции кода недостаточно)
  • Объекты на время подготовки должны быть на сцене
  • Подготовка не затрагивает префабы в проекте (если не сохранить их со сцены явно) и объекты на других сценах


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

Бонус для дочитавших

Особенности получения отсутствующих компонентов



Интересной особенностью оказалось то, что попытка получить отсутствующий компонент занимает больше времени, чем получение существующего. При этом в редакторе наблюдается заметная аномалия, которая и навела на мысль проверить это поведение. Так что никогда не полагайтесь на результаты профилирования в редакторе.
image
Интерактивный вариант


Заключение



В данной статье вы увидели оценку различных методов кэширования компонентов, а также узнали об одном из полезных применений атрибутов. Методы, основанные на рефлексии, в принципе, могут применяться при создании проектов на Unity3D, если учитывать его особенности. Один из них позволяет писать меньше однотипного кода, но чуть менее производителен, чем решение “в лоб”. Второй на данный момент требует чуть больше внимания, но не влияет на итоговую производительность.

Проект с исходниками скриптов для теста и proof-of-concept кэша с помощью атрибутов доступны на GitHub (отдельный пакет с итоговой версией здесь). Возможно, у вас найдутся предложения по улучшению.

Спасибо за внимание, надеюсь на полезные комментарии. Наверняка этот вопрос рассматривался многими и вам есть что сказать по этому поводу.

UPDATE
В последней доступной версии (0.32) добавлены 2 новые фичи:
  1. Отдельный класс для кэширующего свойства ()
  2. При использовании режима «в редакторе» перед сборкой сцены будет проведено кэширование нужных компонентов и выведено предупреждение, если что-то не было закэшировано заранее с помощью пункта меню (к сожалению, предложить сохранить сцену в OnPostProcessScene нельзя).
@KonH
карма
17,0
рейтинг 0,8
Пользователь
Похожие публикации
Самое читаемое Разработка

Комментарии (21)

  • +2
    А можно подменять свойство, что приведет к автоматическому ускорению с использованием старого кода, например, так. В примере получается х2 ускорение + еще х2 ускорение (в результате получается х4 от исходного) если использовать кешированную переменную в классах-наследниках, потому что дергание свойства — это по сути вызов метода с вложенными проверками, которые можно убрать.
    • 0
      Да, это вполне вариант для старого кода. Его производительность тоже рассмотрена в статье (вариант свойства с флагом).
      Такой вопрос — а зачем вам дополнительная проверка _cachedTransform == null в Awake()?
      И спасибо за участие в обсуждении)
      • 0
        Его производительность тоже рассмотрена в статье

        Тут акцент был именно на слове «new» перед transform — мы переопределяем поведение этого свойства на свое — закешированное, нет необходимости переписывать старый код под CachedTransform, можно использовать «штатный» transform.

        проверка _cachedTransform == null в Awake()

        Об этом я уже написал:
        если использовать кешированную переменную в классах-наследниках, потому что дергание свойства — это по сути вызов метода с вложенными проверками, которые можно убрать.

        Те это что-то типа гарантии что трансформ закеширован на старте и дальше во всех методах этого класса и классов-наследников можно напрямую щупать _cachedTransform — будет еще двойное ускорение по сравнению с закешированным «штатным» transform.
        Но тут есть тонкость — если компонент повесить на GO и выключить GO в редакторе до старта — awake не отработает пока GO не будет активирован. Те компонент вроде как и существует, но Awake не пройден. Те можно получить ситуацию с неопределенным _cachedTransform, если повесить компонент на выключенный GO + добавить его через инспектор в другой компонент на включенном GO и подергать паблик-методы, связанные с кешированным трансформом. Тут уже нужно самому следить за логикой поведения, либо игнорировать _cachedTransform и работать только через transform.
        • 0
          Да, теперь понятно, спасибо.
  • +2
    Еще 2 замечания по поводу репы на гитхабе:
    1. Не указана лицензия использования. Ее отсутствие автоматически запрещает использование кода.
    2. Примеры в описании ошибочны — нельзя трогать rigidbody в Update, только в FixedUpdate, не нужно учить плохому.
    • 0
      1. Добавил лицензию
      2. Да, вы правы. Примеры были из разряда «сделать что угодно», так что об этом успешно было забыто. Исправлено
  • 0
    Что означает 0% в WebGL на графике «Сравнение производительности»?
    image

    Судя по графикам, получение свойства через GetComponent<T>() в Unity5 настолько быcтрый, что значительно быстрее получения из словаря. Может, разница настолько мала, что нету смысла стараться писать всякие ужасные конструкции и остановится на лаконичности и надежности кода и оптимизировать другие вещи? Тем более, что ни один из более быстрых способов не проверяет компонент на существование.
    • 0
      Для WebGL цифры из профайлера довольно странные, на 1000 вызов тестовых методов c 0% занимает 0.00 ms, а если увеличивать количество вызовов, то это приводит к рандомным вызовам сборщика мусора, который ломает статистику. Так что сложно сказать, как это адекватно померить.
      Да, так и есть, быстрее GetComponent только изначальное сохранение компонентов при инициализации или вообще не в рантайме, последние варианты именно об этом.
      Конечно, тут нужно смотреть на конкретику и использовать профайлер — кэшировать данные обычно имеет смысл для объектов, которые часто создаются либо используются или их количество реально большое.
      • 0
        Для WebGL цифры из профайлера довольно странные, на 1000 вызов тестовых методов c 0% занимает 0.00 ms,

        Может, вы замеряете каждый отдельный метод, а потом считаете их сумму? Что-то вроде:
        loop {
          start = now();
          GetComponent(Class);
          sum = now() - start();
        }
        


        Если использовать не perfomance.now(), а Date.now(), то все, что меньше миллисекунды в целом будет считаться как 0.
        • 0
          Использовался встроенный профайлер, в котором замерялся кусок кода с N инсттрукций в цикле, так что видимо не в этом дело.
  • 0
    KonH у вас есть реальный проект, где вы применяете один из ваших методов кеширования (кроме того, что автоматический назначает переменные в редакторе) и вы получили необходимый прирост фпс или полной загрузки сцены?
    • +1
      Довольно широко использовали свойства, прирост производительности при большой частоте обращений наблюдался.
      • 0
        Спасибо!
  • 0
    И еще — в случае перекрёстных ссылок, получается, что где-то будет использоваться редактор, где-то аттрибут, где-то аксессор. Я могу ошибаться, но меня и моих коллег, вероятно, раздражало бы различная инициализация ссылок на одинаковые по смыслу объекты
    • +1
      Да, это одна из проблем данного подхода, верно замечено. К сожалению, универсальный вариант пока сложно найти.
  • –2
    Спасибо за статью! Правда хочу заметить что, Unity вроде как уже не имеет приставки 3D. Хотя трудно сказать, на сайте приписки 3д нет но есть в адресе. Интересно как юридически называется.
  • 0
    В каждом новом проекте приходится вычищать такие вот кеши, которые увеличивают сложность и ухудшают читаемость ради преждевременной оптимизации. А варианты с отдельным классом под кеши это еще и дополнительная связанность, даже в самой юньке наконец то догадались об этом и все кеши сделали обсолет.
    • 0
      Если бы была хотя бы попытка потестировать эти «кеши», то было бы понятно, что кешированием там и не пахло — по скорости оно было соизмеримо с постоянным GetComponent(typeof(T)). Юнитеки просто почистили апи от бесполезных свойств. Кеширование они прикрутили только к transform, но все-равно — ручное кеширование в несколько раз быстрее текущего «кешированного» варианта.
  • +1
    >>Например, в вашей игре есть пулемет, который стреляет пулями, каждая из которых является отдельным объектом (что само по себе неправильно, но это же сферический пример)

    А почему неправильно? Я всегда использую абстрактный класс-скрипт с функцией SetDamage, от которого наследую и определяю SetDamage для каждого объекта, который может получить урон (соответственно, каждый объект и получает такой скрипт-потомок). Пуля же, сталкиваясь с объектом, просто вызывает эту функцию и урон наносится. Способ сам придумал, хотелось бы понять, почему так лучше не делать. Пули в пуле, если что, непонятно, почему им объектами быть не следует.
    • +1
      Если не требуется отображать сами пули (то есть пользователь их не видит в силу скорости или этим можно пренебречь), то достаточно системы партиклей и использование Raycast. Если их нужно видеть в 2д (и чуть сложнее в 3д) — то также может быть достаточно рейкастов и проверки их коллизий (но тут нужно сравнивать производительность, не берусь сходу утверждать).
      В любом случае, если требуется создавать и уничтожать объекты много и часто — нужно как минимум использовать пул.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.