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



Прошло некоторое время с тех пор, как я начал делать игры для 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, если интересно посмотреть такой подход в действии. К сожалению прямые ссылки на приложения оставлять нельзя.

Вот, собственно, и всё. Спасибо за внимание.
Поделиться публикацией

Комментарии 27

    +1
    точно так же делаю, только у меня верстка UI на mxml, и чтобы вообще красиво было запилил обертку для feathers https://github.com/flexsurfer/feathers_mxml_spark, т.е. задаешь все размеры как на макете и не думаешь, все само позиционируется, вектора запекаются красивого нужного размера, жизнь кайф, только хот релоада не хватает :)
      0
      Тут явно есть лишние операции, по созданию XML и TextureAtlas.
      Можно сделать свой внутренний хэш для сабтекстур и использовать его в методе getSprite
        0
        Это интересно, опишите пожалуйста подробнее про лишний XML и внутренний хэш для сабтекстур.
          +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);
            0
            Андрей спасибо, понял. Да, возможно без 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 -> 37

            Еще в чате писали про использование ATF, вот это действительно интересно, нужно попробовать.
              0
              Написал такой велосипед вчера, без 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;
              }
              

              0
              Если я правильно вас понял, то при таком подходе у вас будет много разных текстур и соответственно возрастет число drawcalls, что не очень хорошо для производительности на мобилках.

              Может лучше сначала создавать атлас

              var atlas:TextureAtlas  = TextureAtlas(texture, null)
              


              а потом разметить его с помощью addRegion();?
                0
                да, все верно, не учел что это работает только для моего случая
          0
          Генерим спрайтшиты, из них делаем TextureAtlas. Число drawcalls конечно зависит от числа атласов и от того как правильно они скомпонованы. К примеру в игре City 2048 куча текстур, из них собрано 13 спрайтшитов + ещё используется один векторный шрифт, число дроуколов обычно 15-17 и не привышает 22.
            +2
            Статья заслушивает отдельного плюса хотя бы за то, что она была написана. Многие вещи могут быть очевидны и даже содержать не полностью универсальное решение. Но автор молодец. Мне показал эту статью противник всего флешового со словами «ух ты, а я так не и смог в Un.ty» ;)

            Кстати, ребята. Не забываем про новый Adobe AIR из labs от 10 Августа 2016.
              +1
              Не совсем, в тему, но можно убрать префекс air. из андроидовских билдов если при сборке установить переменную среды AIR_NOANDROIDFLAIR = true :)
                0
                актуально про префикс. Некоторые паблишеры категорически против этого префикса.
                0
                Сколько времени уходит при запуске игры для подготовки графики? Вы кешируете ее для повторного использования при следующем запуске?
                  0
                  Это зависит от атласа, от их количества, от размера, от устройства. На моем устройстве один полный атлас готовится за 25-40мс. Как я уже писал, в City 2048 используется 13 атласов, можете скачать и посмотреть на своем устройстве как это быстро происходит. По поводу кеша, специально сам ничего не кеширую.
                  0
                  А сколько в развернутом виде занимают у Вас все атласы в игре? Сколько в норме занятой памяти/GPU памяти/драуколов для as3-игры под web/AIR (mobile)? Интересует приблизительное значение, просто для сравнения со своими показателями.
                    0
                    Могу сказать, на примере опять же City 2048, сколько занимают все атласы в ресурсах — 410кб. В развернутом виде (посмотрел по профайлеру) примерно 17мб, как занимал бы обычный растровый спрайтшит. Но опять же это зависит от размера экрана, от устройства. Дроуколов в пике 22.

                    0
                    А поделитесь, кто как покадровую анимацию делает? Когда невозможно сделать пограммно. Я пока просто вектор поверх кладу, даже сделал feathersui контейнер, но тормозит конечно на нек-х устройствах, запекать покадрово будет жирно по памяти думаю, если запекать при старте будет долго по времени и по памяти, если запекать каждый раз перед стартом анимации будет задержка, вот не знаю даже
                      0
                      пробуй dragonbones
                        0
                        пробовал, это боль, в моем случае, ну т.е. там много чего не поддерживается, когда я смотрел не было альфа канала, на то что бы перевести туда 2с аниамцию, ушло пару дней, потом все это встроить в мой проект, еще много боли, вобщем решил оставить вектор пока
                          0
                          тогда опиши свою анимацию ибо бывают случаи, когда ты хочешь больше, чем можно :) А так-то есть же flump, gaf и т.д.
                            0
                            да скорее всего, т.к. я ленив, я хочу чтобы оно само все там красиво под размеры масштабировалось, и на ретине все четенько было )
                            со статикой решение в этой статье
                            с шрифтами тоже решилось — http://wiki.starling-framework.org/manual/distance_field_fonts
                            c размерами в featherui тоже — https://github.com/flexsurfer/feathers_mxml_spark
                            вот хотелось бы и с анимациями
                              0
                              Небольшой некропостинг, но:
                              По покадровым анимациям есть гиковый DIY способ, который, как мне кажется, в контексте starling и flash в целом ещё никто не реализовывал (хотя подход на самом деле не новый)
                              GDC 2014: Texture Wranglin'
                              Если коротко — покадровая анимация разбивается на «палитру» из пикселей размером 4х4, без повторения (одинаковые наборы 4х4 мапятся к одному элементу «палитры»). «Текстура» кадра хранится в виде lookup table, где прописаны индексы элементов палитры. В итоге атлас 2048х2048 может занимать 512х256 пикселей в зависимости от степени похожести кадров, плюс уменьшенные в 4 раза начальные текстуры (можно ещё дополнительно сэкономить на битности и канальности текстуры)
                              Но тут точно AGAL придётся ковырять (я с ним вообще не работал если честно)
                                0
                                Если не ошибаюсь, то так работает Animate CC >>> WebGL
                                  0
                                  А есть где поподробней почитать?
                                    +1
                                    Деталей найти не получится, т.к. официально все в preview стадии замерло. Но поставив Animate CC можно увидеть всё, что это действительно работает. WebGL таргет это не as3 > js. Это просто как и Canvas — на JS. Они пытались года полтора назад сделать нормальный вывод в WebGL, но тема заглохла, т.к. WebGL как и флеш — работает так же хорошо, как и не везде. Но вот атласы они экспортируют хитро. Картинки большие бьются на маленькие фрагменты.
                                      0
                                      То есть векторная (?) анимация в Animate CC экспортируется в виде покадровой битмапы?
                                        0
                                        Вектор экспортируется в виде Mesh с кусочками текстуры

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое