Прошло некоторое время с тех пор, как я начал делать игры для 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
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, если интересно посмотреть такой подход в действии. К сожалению прямые ссылки на приложения оставлять нельзя.
Вот, собственно, и всё. Спасибо за внимание.
Комментарии (21)
k0t0vich
15.08.2016 16:03Тут явно есть лишние операции, по созданию XML и TextureAtlas.
Можно сделать свой внутренний хэш для сабтекстур и использовать его в методе getSpriteArman11
15.08.2016 16:15Это интересно, опишите пожалуйста подробнее про лишний XML и внутренний хэш для сабтекстур.
motor4ik
15.08.2016 16:37+1я тоже не использую XML и TextureAtlas, просто создаю _atlas = {}; и потом _atlas[Textures.BACKGROUND_LIGHT] = getTextureFromMovieClip(movieClipsAtlas.background_light); и где нужно потом getTexture(Textures.BACKGROUND_LIGHT):Texture
вот кстати настройки при которых запекание текстур происходит в несколько раз быстрее
var canvasData:BitmapData = new BitmapData(roundToPrecision(spr.width), roundToPrecision(spr.height), true, 0x00000000);
canvasData.drawWithQuality(spr, null, null, null, null, false, StageQuality.BEST);
var txt:Texture = Texture.fromBitmapData(canvasData, false, true);Arman11
15.08.2016 18:32Андрей спасибо, понял. Да, возможно без XML лучше и писанины немного меньше, но я хотел именно старлинговый TextureAtlas юзать. Нужно попробовать без XML.
По поводу новых параметров для отрисовки. Протестил на своем девайсе (Huawei P8), и никакого буста не увидел, даже маленького.
Вот результаты==== on device with OLD params
[Test 1]
[trace] ### numberAtlas -> 8
[trace] ### tileAtlasShowFour_x2 -> 36
[trace] ### tileAtlasShowThree_x2 -> 38
[trace] ### tileAtlasShowTwo_x2 -> 28
[trace] ### tileAtlasShowOne_x2 -> 43
[trace] ### tileAtlasTwo -> 33
[trace] ### tileAtlasOne -> 33
[trace] ### tileAtlasNewTwo -> 36
[trace] ### tileAtlasNewOne -> 34
[trace] ### tileAtlasFx -> 29
[trace] ### guiAtlasTutor -> 39
[trace] ### guiAtlasPromo -> 30
[trace] ### guiAtlas -> 34
[Test 2]
[trace] ### numberAtlas -> 8
[trace] ### tileAtlasShowFour_x2 -> 34
[trace] ### tileAtlasShowThree_x2 -> 39
[trace] ### tileAtlasShowTwo_x2 -> 34
[trace] ### tileAtlasShowOne_x2 -> 42
[trace] ### tileAtlasTwo -> 34
[trace] ### tileAtlasOne -> 29
[trace] ### tileAtlasNewTwo -> 34
[trace] ### tileAtlasNewOne -> 31
[trace] ### tileAtlasFx -> 29
[trace] ### guiAtlasTutor -> 40
[trace] ### guiAtlasPromo -> 36
[trace] ### guiAtlas -> 38
[Test 3]
[trace] ### numberAtlas -> 8
[trace] ### tileAtlasShowFour_x2 -> 38
[trace] ### tileAtlasShowThree_x2 -> 35
[trace] ### tileAtlasShowTwo_x2 -> 28
[trace] ### tileAtlasShowOne_x2 -> 43
[trace] ### tileAtlasTwo -> 44
[trace] ### tileAtlasOne -> 31
[trace] ### tileAtlasNewTwo -> 33
[trace] ### tileAtlasNewOne -> 33
[trace] ### tileAtlasFx -> 30
[trace] ### guiAtlasTutor -> 39
[trace] ### guiAtlasPromo -> 29
[trace] ### guiAtlas -> 41
==== on device with NEW params
[Test 1]
[trace] ### numberAtlas -> 10
[trace] ### tileAtlasShowFour_x2 -> 44
[trace] ### tileAtlasShowThree_x2 -> 52
[trace] ### tileAtlasShowTwo_x2 -> 48
[trace] ### tileAtlasShowOne_x2 -> 58
[trace] ### tileAtlasTwo -> 41
[trace] ### tileAtlasOne -> 35
[trace] ### tileAtlasNewTwo -> 40
[trace] ### tileAtlasNewOne -> 37
[trace] ### tileAtlasFx -> 36
[trace] ### guiAtlasTutor -> 45
[trace] ### guiAtlasPromo -> 33
[trace] ### guiAtlas -> 39
[Test 2]
[trace] ### numberAtlas -> 8
[trace] ### tileAtlasShowFour_x2 -> 35
[trace] ### tileAtlasShowThree_x2 -> 41
[trace] ### tileAtlasShowTwo_x2 -> 33
[trace] ### tileAtlasShowOne_x2 -> 43
[trace] ### tileAtlasTwo -> 36
[trace] ### tileAtlasOne -> 33
[trace] ### tileAtlasNewTwo -> 37
[trace] ### tileAtlasNewOne -> 43
[trace] ### tileAtlasFx -> 31
[trace] ### guiAtlasTutor -> 40
[trace] ### guiAtlasPromo -> 31
[trace] ### guiAtlas -> 38
[Test 3]
[trace] ### numberAtlas -> 8
[trace] ### tileAtlasShowFour_x2 -> 38
[trace] ### tileAtlasShowThree_x2 -> 39
[trace] ### tileAtlasShowTwo_x2 -> 33
[trace] ### tileAtlasShowOne_x2 -> 43
[trace] ### tileAtlasTwo -> 36
[trace] ### tileAtlasOne -> 32
[trace] ### tileAtlasNewTwo -> 35
[trace] ### tileAtlasNewOne -> 33
[trace] ### tileAtlasFx -> 30
[trace] ### guiAtlasTutor -> 39
[trace] ### guiAtlasPromo -> 32
[trace] ### guiAtlas -> 37dm62
17.08.2016 10:03Написал такой велосипед вчера, без XTML.
Вроде все отлично работает, только я использовал frame вместо вместо pivotpoint, поэтому starling ругается.
[Starling] Warning: frames inside the texture's region are unsupported.
Заголовок спойлераpublic function createAtlassFromSwc(c:Class, scale:Number = 1):TextureAtlas { var mc:MovieClip = new c as MovieClip; var matrix:Matrix = new Matrix(); matrix.scale( scale,scale); var bitmapData:BitmapData = new BitmapData(512 * scale, 512 * scale, true,0x00FFFFFF); bitmapData.drawWithQuality(mc,matrix,null,null,null,false,StageQuality.BEST); var texture:Texture = Texture.fromBitmapData(bitmapData,false,false,scale); var textureAtlas:TextureAtlas = new TextureAtlas(texture); for (var i:int = 0; i < mc.numChildren; i++) { var displayObject:DisplayObject = mc.getChildAt(i); var rect:Rectangle = displayObject.getRect(displayObject.parent); var frameRect:Rectangle = new Rectangle((displayObject.x - rect.x) , (displayObject.y - rect.y) , 1, 1); textureAtlas.addRegion(displayObject.name, rect, frameRect); } return textureAtlas; }
dm62
16.08.2016 13:12Если я правильно вас понял, то при таком подходе у вас будет много разных текстур и соответственно возрастет число drawcalls, что не очень хорошо для производительности на мобилках.
Может лучше сначала создавать атлас
var atlas:TextureAtlas = TextureAtlas(texture, null)
а потом разметить его с помощью addRegion();?
Arman11
16.08.2016 13:21Генерим спрайтшиты, из них делаем TextureAtlas. Число drawcalls конечно зависит от числа атласов и от того как правильно они скомпонованы. К примеру в игре City 2048 куча текстур, из них собрано 13 спрайтшитов + ещё используется один векторный шрифт, число дроуколов обычно 15-17 и не привышает 22.
TheRabbitFlash
16.08.2016 14:19+2Статья заслушивает отдельного плюса хотя бы за то, что она была написана. Многие вещи могут быть очевидны и даже содержать не полностью универсальное решение. Но автор молодец. Мне показал эту статью противник всего флешового со словами «ух ты, а я так не и смог в Un.ty» ;)
Кстати, ребята. Не забываем про новый Adobe AIR из labs от 10 Августа 2016.
Elegar
16.08.2016 16:01+1Не совсем, в тему, но можно убрать префекс air. из андроидовских билдов если при сборке установить переменную среды AIR_NOANDROIDFLAIR = true :)
TheRabbitFlash
16.08.2016 17:00актуально про префикс. Некоторые паблишеры категорически против этого префикса.
alexvoz
17.08.2016 14:07Сколько времени уходит при запуске игры для подготовки графики? Вы кешируете ее для повторного использования при следующем запуске?
Arman11
17.08.2016 19:44Это зависит от атласа, от их количества, от размера, от устройства. На моем устройстве один полный атлас готовится за 25-40мс. Как я уже писал, в City 2048 используется 13 атласов, можете скачать и посмотреть на своем устройстве как это быстро происходит. По поводу кеша, специально сам ничего не кеширую.
slavik_yad
18.08.2016 14:19А сколько в развернутом виде занимают у Вас все атласы в игре? Сколько в норме занятой памяти/GPU памяти/драуколов для as3-игры под web/AIR (mobile)? Интересует приблизительное значение, просто для сравнения со своими показателями.
Arman11
18.08.2016 15:13Могу сказать, на примере опять же City 2048, сколько занимают все атласы в ресурсах — 410кб. В развернутом виде (посмотрел по профайлеру) примерно 17мб, как занимал бы обычный растровый спрайтшит. Но опять же это зависит от размера экрана, от устройства. Дроуколов в пике 22.
motor4ik
25.08.2016 11:04А поделитесь, кто как покадровую анимацию делает? Когда невозможно сделать пограммно. Я пока просто вектор поверх кладу, даже сделал feathersui контейнер, но тормозит конечно на нек-х устройствах, запекать покадрово будет жирно по памяти думаю, если запекать при старте будет долго по времени и по памяти, если запекать каждый раз перед стартом анимации будет задержка, вот не знаю даже
TheRabbitFlash
25.08.2016 11:06пробуй dragonbones
motor4ik
25.08.2016 11:13пробовал, это боль, в моем случае, ну т.е. там много чего не поддерживается, когда я смотрел не было альфа канала, на то что бы перевести туда 2с аниамцию, ушло пару дней, потом все это встроить в мой проект, еще много боли, вобщем решил оставить вектор пока
TheRabbitFlash
25.08.2016 11:14тогда опиши свою анимацию ибо бывают случаи, когда ты хочешь больше, чем можно :) А так-то есть же flump, gaf и т.д.
motor4ik
25.08.2016 11:38да скорее всего, т.к. я ленив, я хочу чтобы оно само все там красиво под размеры масштабировалось, и на ретине все четенько было )
со статикой решение в этой статье
с шрифтами тоже решилось — http://wiki.starling-framework.org/manual/distance_field_fonts
c размерами в featherui тоже — https://github.com/flexsurfer/feathers_mxml_spark
вот хотелось бы и с анимациями
motor4ik
точно так же делаю, только у меня верстка UI на mxml, и чтобы вообще красиво было запилил обертку для feathers https://github.com/flexsurfer/feathers_mxml_spark, т.е. задаешь все размеры как на макете и не думаешь, все само позиционируется, вектора запекаются красивого нужного размера, жизнь кайф, только хот релоада не хватает :)