В этой статья мне хотелось бы рассказать о том, как была ускорена отрисовка монстров при создании игры Alien Massacre. Данное решение подойдет для любых проектов, которые испольуют спрайтовую анимацию.
В результате разработки мобильной игры оказалось, что достаточно узким местом стало проигрывание большого количества анимированных объектов на сцене. В результате сформировались следующие требования:
- 1 Необходимо обеспечить отрисовку большого числа анимированных объектов на сцене. Ведь мы хотим, чтобы игрок отстреливался от полчищ монстров.
- 2 Прогресс анимации должен быть различен для каждого из объектов. Ведь мы не хотим, чтобы мобы ходили строем.
Решение «из коробки»
Безусловно, первое решение было простым: все сделать с помощью уже встроенного в UnityEngine компонента Animator. Посмотрим, что из этого получается.
В качестве атласа с исходной анимацией будем использовать зломонстра с 24 кадрами спрайтовой анимации 64х64 пикселя каждый:
В Unity3D задаем тип текстуры sprite и в SpriteEditor нарезаем его на 24 куска. Делаем для него анимацию и закидываем все это на пустой объект. Тут самое время вспомнить о том, что у нас было условие про различный прогресс анимации для различных объектов. Не вопрос! Минута работы и скрипт готов.
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
- Создадим компонент, который будет рассчитывать «глобальный» прогресс анимации
Начнем с шейдера отрисовки.
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.
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 все это должно смотреться следующим образом:
Ну и теперь осталось самое простое, написать глобальный счетчик кадров. Вот и он:
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 затягивается любой из множества контролируемых объектов.
Тестирование
Что же, подошло время для тестирования. Для теста делаем небольшой скрипт, который позволяет создавать желаемое количество объектов на сцене.
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, как это сделать показано в демо проекте.
Демо проект можно скачать здесь.
Автор: Philipp0K