Adobe AIR + Starling + растеризация векторной графики +19




Прошло некоторое время с тех пор, как я начал делать игры для iOS и Android на Adobe AIR. Сегодня хочу поделиться способом создания игр под различные разрешения экранов — этот подход я успешно применяю в своих проектах.

Как известно, есть несколько способов подготовки игровой графики для разных разрешений экранов:

Использовать несколько паков с графикой


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

Рисовать пиксель-арт


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

Векторная графика


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

Но, не всё так просто. Дело в том, что вся векторная графика обрабатывается на CPU, а значит игра с такой графикой на телефоне обречена на тормоза, да и сильно не разбежишься (объектов на экране получается мало да и те должны быть простыми без лишней детализации). Хотя первая версия моей игры City 2048 была именно такой, и на удивление работала вполне себе прилично, выдавала 25-40 fps. Запуская тестовую версию игры, я ожидал что телефон прям у меня в руках зависнет и расплавится от этого, но нет. Так же могу сказать, что ещё одна моя игра Dots Tails до сих пор работает с использованием векторной графики, есть на то свои причины.

Чтобы увеличить производительность, необходимо отрисовать всю игровую графику на GPU, для этого будем использовать Stage3D и Starling. Получается что из отдельных векторных элементов, нужно составить растровые спрайтшиты сразу нужного размера в процессе выполнения приложения. О том как это реализовать мы и поговорим.

Перед употреблением, векторную графику необходимо растянуть до нужного размера, разложить на атлас и запечь. Для этих целей я использовал слегка изменённый класс от Emiliano Angelini «Dynamic Texture Atlas and Bitmap Font Generator», оставив от него только создание простого атласа текстур без анимаций.

Принцип работы следующий:

1. Рисуем арт для игры в Adobe Flash Pro (или любом другом векторном редакторе и переносим во Flash Pro)



2. Создаём спрайт который будет содержать в себе элементы графики, делаем его доступным для AS. Именно из него мы и будем делать спрайтшит.



3. Запихиваем в этот спрайт нужную нам графику. Я старался разместить элементы так, чтобы они влезали в размер 512х512. Это необходимо, так как при скейле размер атласа не должен быть больше 4к. Для дизайн макета я всегда использую размер 600х800, так нарисованные и скомпонованные элементы хорошо смотрятся и не вылезают за размер 2к. Так-же элементы графики стоит компоновать по тематике, к примеру у меня в играх слой с GUI лежит над игровой графикой, по этому я делаю два отдельных атласа с GUI и с игровыми элементами + если в игре несколько визуально разных уровней то лучше раскидать эти элементы по разным атласам. Это поможет сократить количество дроуколов.



4. Каждому элементу в атласе не забываем присвоить имя.



5. Экспортируем .swc с ресурсами и подключаем его к проекту.



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

// Размер экрана нашего устройства, к примеру iPad2
var _stageWidth:Number = 768;
var _stageHeight:Number = 1024;

// Размер дизайн-макета
var defaultScreenWidth:Number = 600;
var defaultScreenHeight:Number = 800;

// Вычисляем скейлы и берём нужный, в зависимости от ориентации экрана. В моём случае портретная
_scaleX = _stageWidth / defaultScreenWidth;
_scaleY = _stageHeight / defaultScreenHeight;
_minScale = Math.min(_scaleX, _scaleY);

7. Добавляем в проект класс TextureManager.as и прописываем в нём имена атласов из SWC

Содержимое класса TextureManager
package com.Extension
{
	import avmplus.getQualifiedClassName;

	import com.Event.GameActiveEvent;
	import com.Module.EventBus;
	import com.greensock.TweenNano;

	import flash.display.Bitmap;
	import flash.display.BitmapData;
	import flash.display.DisplayObject;
	import flash.display.Sprite;
	import flash.display.StageQuality;
	import flash.geom.Matrix;
	import flash.geom.Rectangle;

	import starling.display.Image;
	import starling.display.Sprite;
	import starling.textures.Texture;
	import starling.textures.TextureAtlas;

	public class TextureManager
	{
		// хранит в себе координаты на которые нужно сдвинуть спрайт, чтобы сохранить PivotPoint объекта из SWC
		private static var textureAdditionalData:Object = {};
		// контейнер с готовыми атласами
		private static var textureAtlases:Vector.<TextureAtlas> = new <TextureAtlas>[];
		// массив атласов которые нужно распарсить 
		// !!! (здесь нужно прописать имена атласов из SWC и скейл)
		private static var toParse:Array = [
			[guiAtlas, ScaleManager.minScale],
			[gameAtlas, ScaleManager.minScale]
		];

		// возвращает старлинговый спрайт с нужной нам текстурой из атласа
		public static function getSprite(textureName:String, smooth:String = "none"):starling.display.Sprite
		{
			if (textureAdditionalData.hasOwnProperty(textureName))
			{
				var addition:Object = textureAdditionalData[textureName];
				var image:Image = new Image(findTexture(textureName));
				image.x = -addition["x"];
				image.y = -addition["y"];
				image.textureSmoothing = smooth;

				var result:starling.display.Sprite = new starling.display.Sprite();
				result.addChild(image);

				return result;
			}

			throw new Error("[!!!] Texture '" + textureName + "' not found.");
		}

		// возвращает текстуру из атласа
		public static function getTexture(textureName:String):Texture
		{
			return findTexture(textureName);
		}

		// метот, который нужно вызвать при старте игры. Если атласов много, то это может занять некоторое время.
		public static function createAtlases():void
		{
			if (!textureAtlases.length)
			{
				nextParseStep();
				return;
			}
			throw new Error("[!!!] Texture atlases already.");
		}

		// поочерёдно создаём атласы
		private static function nextParseStep():void
		{
			if (toParse.length)
			{
				var nextStep:Array = toParse.pop();
				TweenNano.delayedCall(.15, TextureManager.createAtlas, nextStep);
			}
			else
			{
				// если всё, то отправляем событие о старте игры.
				EventBus.dispatcher.dispatchEvent(new GameActiveEvent(GameActiveEvent.GAME_START, true));
			}
		}

		// поиск нужной текстуры в атласах
		private static function findTexture(textureName:String):Texture
		{
			var result:Texture;
			for each (var atlas:TextureAtlas in textureAtlases)
			{
				result = atlas.getTexture(textureName);
				if (result)
				{
					return result;
				}
			}

			throw new Error("[!!!] Texture '" + textureName + "' not found.");
		}

		// класс который парсит спрайты из SWC и создаёт атлас
		private static function createAtlas(swcPack:Class, scaleFactor:Number):void
		{
			var pack:flash.display.Sprite = (new swcPack()) as flash.display.Sprite;
			var itemsHolder:Array = [];
			var canvas:flash.display.Sprite = new flash.display.Sprite();

			var children:uint = pack.numChildren;
			for (var i:uint = 0; i < children; i++)
			{
				var selected:DisplayObject = pack.getChildAt(i);
				var realX:Number = selected.x;
				var realY:Number = selected.y;
				selected.scaleX *= scaleFactor;
				selected.scaleY *= scaleFactor;

				var bounds:Rectangle = selected.getBounds(selected.parent);
				bounds.x = Math.floor(bounds.x - 1);
				bounds.y = Math.floor(bounds.y - 1);
				bounds.height = Math.round(bounds.height + 2);
				bounds.width = Math.round(bounds.width + 2);
				var drawRect:Rectangle = new Rectangle(0, 0, bounds.width, bounds.height);

				var bData:BitmapData = new BitmapData(bounds.width, bounds.height, true, 0);
				var mat:Matrix = selected.transform.matrix;
				mat.translate(-bounds.x, -bounds.y);
				bData.drawWithQuality(selected, mat, null, null, drawRect, false, StageQuality.BEST);

				var pivotX:int = Math.round(realX - bounds.x);
				var pivotY:int = Math.round(realY - bounds.y);

				textureAdditionalData[selected.name] = {x:pivotX, y:pivotY};
				var item:flash.display.Sprite = new flash.display.Sprite();
				item.name = selected.name;
				item.addChild(new Bitmap(bData, "auto", false));
				itemsHolder.push(item);
				canvas.addChild(item);
			}

			layoutChildren();

			var canvasData:BitmapData = new BitmapData(canvas.width, canvas.height, true, 0x000000);
			canvasData.draw(canvas);

			var xml:XML = new XML(<TextureAtlas></TextureAtlas>);
			xml.@imagePath = (getQualifiedClassName(swcPack) + ".png");

			var itemsLen:int = itemsHolder.length;
			for (var k:uint = 0; k < itemsLen; k++)
			{
				var itm:flash.display.Sprite = itemsHolder[k];

				var subText:XML = new XML(<SubTexture />);
				subText.@name = itm.name;
				subText.@x = itm.x;
				subText.@y = itm.y;
				subText.@width = itm.width;
				subText.@height = itm.height;
				xml.appendChild(subText);
			}
			var texture:Texture = Texture.fromBitmapData(canvasData);
			var atlas:TextureAtlas = new TextureAtlas(texture, xml);
			textureAtlases.push(atlas);

			function layoutChildren():void
			{
				var xPos:Number = 0;
				var yPos:Number = 0;
				var maxY:Number = 0;
				var maxW:uint = 512 * ScaleManager.atlasSize;
				var len:int = itemsHolder.length;

				var itm:flash.display.Sprite;

				for (var i:uint = 0; i < len; i++)
				{
					itm = itemsHolder[i];
					if ((xPos + itm.width) > maxW)
					{
						xPos = 0;
						yPos += maxY;
						maxY = 0;
					}
					if (itm.height + 1 > maxY)
					{
						maxY = itm.height + 1;
					}
					itm.x = xPos;
					itm.y = yPos;
					xPos += itm.width + 1;
				}
			}

			nextParseStep();
		}

		public function TextureManager()
		{
			throw new Error("[!!!] Used private class.");
		}
	}
}

Немного подробнее о том, что происходит в методе createAtlas:

» 7.1. Каждый элемента в атласе из SWC скейлим, сохраняем координаты для PivotPoint, отрисовываем в Bitmap и добавляем в контейнер canvas.

» 7.2. Расставляем элементы в контейнере canvas друг за другом, так чтобы влезли в нужный размер атласа

» 7.3. Контейнер canvas рисуем в BitmapData и генерим .XML

» 7.4. Из полученных BitmapData и .XML создаём старлинговый TextureAtlas

» 7.5. Полученный атлас добавляем в контейнер textureAtlases

8. При старте игры создаём атласы для старлинга

TextureManager.createAtlases();

9. Добавляем нужный нам спрайт на сцену

var tileView:starling.display.Sprite = TextureManager.getSprite("rotateView");
this.addChild(tileView);

Что получаем в итоге? Красивую графику, которая практически ничего не весит, тянется на сколь угодно большой размер экрана без потери качества. При этом игра работает на стабильных 60fps. Ну и лично для меня ещё один плюс в том что в векторе достаточно просто рисовать, хоть я и не художник, но кое что в векторе могу.



Растеризацию векторной графики я использую в своих играх City 2048, Quadtris и Placid Place. Которые можно найти в Apple App Store и Google Play, если интересно посмотреть такой подход в действии. К сожалению прямые ссылки на приложения оставлять нельзя.

Вот, собственно, и всё. Спасибо за внимание.
-->


К сожалению, не доступен сервер mySQL