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

в 19:02, , рубрики: C#, unity3d, геймдев, кэширование, разработка игр, теги всё равно никто не читает

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

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

Стоит отметить, что говорить мы будем в основном о “внутреннем” кэшировании, то есть получении тех компонентов, которые есть на текущем объекте для его внутренних нужд. Для начала откажемся от прямого назначения зависимостей в инспекторе — это неудобно в использовании, засоряет настройки скрипта и может привести к битым ссылкам при вылете редактора. Поэтому будем использовать 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 (отдельный пакет с итоговой версией здесь). Возможно, у вас найдутся предложения по улучшению.

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

Автор: KonH

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js