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

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

  • 1 Необходимо обеспечить отрисовку большого числа анимированных объектов на сцене. Ведь мы хотим, чтобы игрок отстреливался от полчищ монстров.
  • 2 Прогресс анимации должен быть различен для каждого из объектов. Ведь мы не хотим, чтобы мобы ходили строем.

Решение «из коробки»


Безусловно, первое решение было простым: все сделать с помощью уже встроенного в UnityEngine компонента Animator. Посмотрим, что из этого получается.

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



В Unity3D задаем тип текстуры sprite и в SpriteEditor нарезаем его на 24 куска. Делаем для него анимацию и закидываем все это на пустой объект. Тут самое время вспомнить о том, что у нас было условие про различный прогресс анимации для различных объектов. Не вопрос! Минута работы и скрипт готов.

AnimationOffset.cs
using UnityEngine;

namespace Kalita
{
    [RequireComponent(typeof(Animator))]
    public class AnimationOffset : MonoBehaviour
    {
        public int Offset;
        public bool IsRandomOffset;

        private void Start()
        {
            var animator = GetComponent<Animator>();
            var runtimeController = animator.runtimeAnimatorController;
            var clip = runtimeController.animationClips[0];
            if (IsRandomOffset)
                Offset = Random.Range(0, (int) (clip.length*clip.frameRate));
            var time = (Offset*clip.length/clip.frameRate);        
            animator.Update(time);
        }
    }
}


Теперь собираем все это в кучу и получаем решение, которое Unity3D предоставляет «из коробки».



Забегая вперед, скажу, что решение «из коробки» имеет достаточно неплохую производительность и высокую гибкость. Настраивать аниматоры уже давно привыкли все, кто работают в Unity3D. Но что делать, если ваше приложение требует большей производительности?

Решение «сделай сам»


Начнем с общего концепта:

  • Сделаем расчет прогресса анимации в вертексном шейдере
  • Закодируем информацию о начальном кадре анимации («локальный прогресс») в альфа канале цвета вертекса (чтобы не потерять батчинг)
  • Создадим компонент, который упрощает настройку анимации в Unity Editor
  • Создадим компонент, который будет рассчитывать «глобальный» прогресс анимации

Начнем с шейдера отрисовки.

KalitaAtlasDrawer.shader
Shader "Kalita/KalitaAtlasDrawer" 
{
	Properties 
	{
		_MainTex ("Texture Atlas (RGBA)", 2D) = "" {}
		_Frame("Frame", float) = 0
		_TotalFrames("Total Frames Count in Sequence", float) = 1
	}

	SubShader 
	{
		Tags { "Queue"="Transparent" }
		Blend SrcAlpha OneMinusSrcAlpha
		Cull Off
			
		pass
		{
			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag
					

			sampler2D _MainTex;
			float4 _MainTex_ST;
			float _Frame;
			float _TotalFrames;

			struct appData
			{
				float4 vertex	: POSITION;
				fixed4 color	: COLOR;
				float2 uv		: TEXCOORD0;
			};

			struct v2f
			{
				float4 pos		: SV_POSITION;
				float2 uv		: TEXCOORD0;
			};

			v2f vert (appData v)
			{
				v2f o;
				o.pos = mul (UNITY_MATRIX_MVP, v.vertex);

				float frame = (_Frame  + v.color.a*255) % (_TotalFrames + 1);
				float offset = frame / _TotalFrames;

				o.uv = v.uv;
				o.uv.x += offset;

				return o;
			}

			fixed4 frag (v2f i) : COLOR
			{
				fixed4 color = tex2D (_MainTex, i.uv);
				return color;
			}
			ENDCG
		}
	} 
	FallBack "Diffuse"
}


Далее перейдем к компоненту, который позволит легко настраивать параметры анимации из Unity Editor.

KalitaAnimation.cs
using UnityEngine;

namespace Kalita
{
    [ExecuteInEditMode]
    [RequireComponent(typeof (MeshFilter))]
    [RequireComponent(typeof (MeshRenderer))]
    public class KalitaAnimation : MonoBehaviour
    {
        public Material RendererMaterial
        {
            get { return meshRenderer.sharedMaterial; }
        }
        
        public Vector2 InGameSize = Vector2.one;
        public Vector2 Anchor = new Vector2(.5f, .5f);
        public int FramesCount = 1;

        public bool IsRandomStartAnimation;
        public byte StartFrame;

        private MeshFilter filter;
        private MeshRenderer meshRenderer;

        private void Awake()
        {
            filter = GetComponent<MeshFilter>();
            meshRenderer = GetComponent<MeshRenderer>();
            BuildMesh();

            SetAnimationOffset();
        }

#if UNITY_EDITOR && !TEST_RUNNING
        private void Update()
        {
            if (Application.isPlaying)
                return;

            BuildMesh();
            SetAnimationOffset();

            var mat = meshRenderer.sharedMaterial;
            mat.mainTextureScale = new Vector2(1f / FramesCount, 1);
        }
#endif

        private void BuildMesh()
        {
            var anchor = Anchor;
            anchor.Scale(InGameSize);
            anchor /= 2;

            var mesh = BuildQuad(InGameSize, anchor, new Vector2(1f / FramesCount, 1f));
            filter.mesh = mesh;
        }

        private void SetAnimationOffset()
        {
            var mesh = filter.sharedMesh;
            mesh.name = "Plane";

            var cnt = mesh.vertexCount;

            var clrs = mesh.colors32;
            if (clrs.Length != cnt)
                clrs = new Color32[cnt];

            if (IsRandomStartAnimation && Application.isPlaying)
                StartFrame = (byte)Random.Range(0, 255);
				
            for (int i = 0; i < cnt; i++)
                clrs[i].a = StartFrame;

            mesh.colors32 = clrs;
        }

        public static Mesh BuildQuad(Vector2 size, Vector2 anchor, Vector2 uvStep)
        {
            var dx = size.x / 2;
            var dy = size.y / 2;
            var vertices = new[]
            {
                new Vector3(-dx + anchor.x, -dy + anchor.y, 0),
                new Vector3(dx + anchor.x, -dy + anchor.y, 0),
                new Vector3(dx + anchor.x, dy + anchor.y, 0),
                new Vector3(-dx + anchor.x, dy + anchor.y, 0),
            };

            var uvs0 = new[]
            {
                uvStep,
                new Vector2(0, uvStep.y),
                new Vector2(0, 0),
                new Vector2(uvStep.x, 0),
            };

            var indices = new[]
            {
                0, 1, 2, 0, 2, 3
            };

            var mesh = new Mesh { vertices = vertices, uv = uvs0, triangles = indices };
            mesh.Optimize();
            return mesh;
        }
    }
}


Этот скрипт работает в редакторе Unity3D и позволяет сразу увидеть изменение любых параметров на сцене, что делает настройку простой и удобной. Не забываем создать материал с выше написанным шейдером и назначить его в MeshRenderer. В редакторе Unity3D все это должно смотреться следующим образом:



Ну и теперь осталось самое простое, написать глобальный счетчик кадров. Вот и он:

KalitaAtlasAC.cs
using UnityEngine;

namespace Kalita
{
    [ExecuteInEditMode]
    public class KalitaAtlasAC : MonoBehaviour
    {
        public KalitaAnimation Animation;
        
        public float FrameRate = 24;
        [HideInInspector]
        public int CurrentGlobalFrame;
        private float lastGlobalFrameUpdateTime;

        private void Awake()
        {
            if (Animation == null)
                Animation = GetComponentInChildren<KalitaAnimation>();
        }

        private void Update()
        {
            if (FrameRate <= 0)
                return;

            var t = Time.time;
            var nextUpdateTime = lastGlobalFrameUpdateTime + 1f/FrameRate;
            if (t < nextUpdateTime)
                return;
            var dt = t - lastGlobalFrameUpdateTime;
            lastGlobalFrameUpdateTime = t;
            //If we run too slow, we shoud add several frames per update
            CurrentGlobalFrame += (int) (dt*FrameRate);
            CurrentGlobalFrame %= Animation.FramesCount;
            Animation.RendererMaterial.SetFloat("_Frame", CurrentGlobalFrame);
        }
    }
}


Для коректной работы один компонент KalitaAtlasAC контролирует множество компонентов KalitaAnimation. Так как параметры устанавливаются через sharedMaterial, то в соответствующее поле (animation) KalitaAtlasAC затягивается любой из множества контролируемых объектов.

Тестирование


Что же, подошло время для тестирования. Для теста делаем небольшой скрипт, который позволяет создавать желаемое количество объектов на сцене.

HabrSpawner.cs
using System.Collections.Generic;
using UnityEngine;

namespace Kalita
{
    public class HabrSpawner : MonoBehaviour
    {
        public List<GameObject> Objects = new List<GameObject>(); 
        public int MobsToSpawn;
        private int mobOnScene;
        public Vector2 SpawnZone = new Vector2(10, 10);
        
        private void Start()
        {
            Screen.sleepTimeout = SleepTimeout.NeverSleep;
            SpawnMany();
        }

        private void Update()
        {
            if (spawnMany)
            {
                spawnMany = false;
                SpawnMany();
            }
        }
		
        [SerializeField]
        private bool spawnMany;
        private void SpawnMany()
        {
            const int layers = 5;
            var rectBorderSize = Vector2.one*2.4f;
            var mobsPerLayer = MobsToSpawn / layers;
            var zone = SpawnZone;
            for (int j = 0; j < layers; j++)
            {
                for (int i = 0; i < mobsPerLayer; i++)
                    Spawn(zone);
                zone -= rectBorderSize;
            }
        }

        private void Spawn(Vector2 zone)
        {
            if (Objects.Count == 0)
                return;

            var i = Random.Range(0, Objects.Count);
            var o = Instantiate(Objects[i]);
            var p = GetRandomPositionOnRect(zone);
            Spawn(o, p);
        }

        private void Spawn(GameObject o, Vector2 pos)
        {
            mobOnScene++;
            o.SetActive(true);
            o.transform.position = pos;
        }

        private void OnGUI()
        {
            var w = 150;
            var h = 20;
            var x = 100;
            var y = 0;
            var rect = new Rect(x, y, w, h);

            //+One mob is source mob
            GUI.Label(rect, "MobsOnScene: " + (mobOnScene + 1));
        }

        private Vector2 GetRandomPositionOnRect(Vector2 size)
        {
            var spawnRect = size;
            var resultPos = new Vector2();

            switch (Random.Range(0, 4))
            {
                case 0: // Top
                    resultPos.x = Random.Range(0, spawnRect.x) - (spawnRect.x) / 2f;
                    resultPos.y = spawnRect.y / 2;
                    break;
                case 1: // Right
                    resultPos.x = spawnRect.x / 2;
                    resultPos.y = Random.Range(0, spawnRect.y) - (spawnRect.y) / 2;
                    break;
                case 2: // Bottom
                    resultPos.x = Random.Range(0, spawnRect.x) - (spawnRect.x) / 2;
                    resultPos.y = -spawnRect.y / 2;
                    break;
                case 3: // Left
                    resultPos.x = -spawnRect.x / 2;
                    resultPos.y = Random.Range(0, spawnRect.y) - (spawnRect.y) / 2;
                    break;
            }
            return resultPos;
        }
    }
}


Сравним результаты. Сперва запустим в UnityEditor с задачей отрисовать 20000 объектов.

При использовании Unity3D Animator на моем ноутбуке Dell M4800 получаем около 5 FPS:



Запускаем туже задачу с KalitaAtlasAC + KalitaAnimation и получаем 20+ FPS:



Что же будет при тестировании на реальном девайсе? Снизим количество создаваемых объектов до 2000, мы же все-таки на мобильном устройстве работать будем. В качестве подопытного под рукой оказался Samsung Galaxy S3 — i9300. При использовании Unity3D Animator получаем около 9-10 FPS:



А при использовании KalitaAtlasAC + KalitaAnimation в результате имеем 35+ FPS:



Итоги


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

Кстати, оставшиеся rgb компоненты цвета вертекса можно использовать в качестве Overlay, как это сделать показано в демо проекте.

Демо проект можно скачать тут: bitbucket.org/Philipp0K/kalitaanimator
Поделиться с друзьями
-->

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


  1. Leopotam
    07.06.2016 13:15

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


    1. Philipp0K
      07.06.2016 16:02

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


  1. Ichimitsu
    07.06.2016 14:22

    Спасибо за статью, полезно.


  1. Igor_Sib
    07.06.2016 14:39

    В инете был доклад Евгения Овчаренко — Lots of 2d кажется назывался, поищите — там он рассказывал как сделать быстрый рендер через партиклы, реально очень быстро работало. Объективности ради можно было бы сравнить еще тот способ.


  1. Leopotam
    07.06.2016 16:09

    Еще вариант — убрать нагрузку от батчинга, потому что юнити тупо проверяет баунд каждого меша на попадание в frustrum камеры и добавляет его в динамик-батчинг. Сделать меш из N*4 вертексов, где N — максимальное количество юнитов, инициализировав их в ноль и обычными квадами по 2 треугольника. Потом в апдейте просто двигать по 4 точки в нужную позицию юнита (обновлять нужно только позиции вертексов и переливать в меш), а в шейдере уже раздвигать их на определенный размер по вектору, заданному, допустим, в uv, а направление брать из z координаты вертекса — получится этакий инстансинг.


    1. Leopotam
      07.06.2016 16:15
      +1

      Ну и в догонку — сорцы на гуглодрайве да еще и с авторизацией? Годная идея, гитхаб / гитлаб / битбакет зафейлились и должны быть немедленно свернуты.


      1. Philipp0K
        07.06.2016 16:43

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


        1. Leopotam
          07.06.2016 16:52

          А почему, кстати, в шейдере (в репе) в v2f используется сигнатура COLOR? Вроде как должна быть сквозная нумерация TEXCOORD0..TEXCOORDn для всех интерполяторов.


          1. Philipp0K
            07.06.2016 17:04

            Для передачи цвета вертекса используется сигнатура COLOR и как раз она и сквозная, если передается и в фрагментный шейдер.
            http://docs.unity3d.com/Manual/SL-VertexProgramInputs.html


            1. Leopotam
              07.06.2016 17:16

              Никакой сквозной (автоматической) передачи нет, данные копируются / модифицируются в вертексном шейдере, а между вертексным / фрагментным шейдером должны передаваться POSITION (или SV_POSITION) + TEXCOORD0..TEXCOORDn:
              http://docs.unity3d.com/Manual/SL-ShaderSemantics.html

              Many modern GPUs don’t really care what semantics these variables have; however some old systems (most notably, shader model 2 GPUs on Direct3D 9) did have special rules about the semantics:
              TEXCOORD0, TEXCOORD1 etc are used to indicate arbitrary high precision data such as texture coordinates and positions.
              COLOR0 and COLOR1 semantics on vertex outputs and fragment inputs are for low-precision, 0–1 range data (like simple color values).

              Те лучше не экспериментировать и использовать сквозную нумерацию (в которой уже можно указать точность типа).


              1. Philipp0K
                07.06.2016 17:38

                Точность и указана как fixed4 т.к. цвет вертекса передается в Color32. В данном примере вертексный шейдер НЕ модифицирует этот цвет и передает в франментный. Использование интерполяторов COLOR, как во входных, так и в выходных данных вертексного шейдера показана в документации Unity3D в разделе Visualizing vertex colors по ссылке, что привел в предыдущем посте.
                Что касается точности данных при вычислениях fixed\half\float, то достаточно давно проводил исследования по этому поводу и, как результат, получилось, что на современных девайсах никакой заметной разницы не было.