Pull to refresh
0
Pixonic
Developing and publishing games since 2009

От int main() до BeginPlay: как происходит инициализация Unreal Engine под капотом

Reading time16 min
Views19K
Original author: Alex Forsythe

Одна из самых простых и базовых концепций в программировании игр — это идея игрового цикла. При запуске вашего проекта происходит его инициализация, после чего игровой цикл действует до тех пор, пока игрок хочет продолжать игру: каждый кадр вы обрабатываете ввод, обновляете состояние игрового мира и выводите результат на экран. Когда игрок закрывает игру, происходит очистка данных, и на этом все заканчивается. 

Но когда вы пишете игровой код на Unreal Engine, вы не имеете дело с игровым циклом напрямую. Вы не начинаете работать сразу с основной функцией — сначала вы определяете подкласс GameMode и переопределяете функцию под названием InitGame. Или пишете одноразовые классы Actor и Component и переопределяете их функции BeginPlay или Tick для добавления собственной логики. Это самый минимум того, что вам нужно сделать: обо всем остальном движок позаботится за вас.

Unreal Engine также предлагает вам как программисту мощный и гибкий инструментарий: конечно, он имеет открытый исходный код, но также возможно и расширение несколькими другими способами. Даже если вы только начинаете работать с этим движком, было бы не лишним получить представление о его GameFramework: о таких классах, как GameMode, GameState, PlayerController, Pawn и PlayerState.

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

Если вы заглянете под капот Unreal Engine и начнете разбираться в его кодовой базе, то найдете там основную функцию — точку входа в программу. Но не так легко может оказаться найти путь от этой точки до того места, где на самом деле выполняется код вашей игры.

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

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

И, честно говоря, на этом этапе неизбежны некоторые неприятные сложности. Это можно сравнить с первыми мгновениями после Большого взрыва: происходит куча вещей сразу, и множество систем перекрывают друг друга на очень небольшой площади. Но к тому моменту, когда вы дойдете до InitGame или BeginPlay и написанного вами игрового кода, вселенная уже успевает расшириться, и все принимает более упорядоченную форму.

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

Все начинается в модуле Launch, где вы найдете различные основные функции, определенные для разных платформ. В конце концов, все они находят свой путь к функции GuardedMain в Launch.cpp. Где-то здесь как раз можно увидеть базовый игровой цикл.

Основной цикл движка реализован в классе FEngineLoop:

Мы видим, что в цикле движка есть этап PreInit, после которого движок полностью инициализируется, а затем мы производим тики на каждом кадре, пока не будем готовы выйти из цикла. Давайте разберемся, что происходит в вызовах этих функций.

PreInit — это место, где загружается большинство модулей. Когда вы создаете игровой проект или плагин с исходным кодом на C++, вы определяете один или несколько исходных модулей в файле .uproject или .uplugin и можете указать в LoadingPhase, когда нужно загрузить этот модуль.

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

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

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

Так что же происходит после загрузки вашего модуля?

Сначала движок регистрирует любые классы UObject, определенные в этом модуле. Это дает системе отражения информацию об этих классах, а также создает CDO, или объект по умолчанию для каждого класса. CDO — это дефолтная запись вашего класса, которая служит прототипом для дальнейшего наследования.

Итак, если вы определили пользовательский тип Актер (Actor), или пользовательский игровой режим, или что-то еще, объявленное перед UCLASS, цикл движка выделяет экземпляр этого класса по умолчанию, затем запускает его конструктор, передавая CDO родительского класса в качестве шаблона. Это одна из причин, по которой конструктор не должен содержать никакого кода, связанного с геймплеем: на самом деле он предназначен только для установления универсальных деталей класса, а не для изменения какого-либо конкретного экземпляра этого класса.

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

Итак, цикл Engine загрузил все необходимые модули движка, проекта и подключаемых модулей, зарегистрировал классы из этих модулей и инициализировал все необходимые низкоуровневые системы. На этом этап PreInit завершается, и мы можем перейти к функции Init. Если мы немного упростим ее, то увидим, что она передает данные в класс под названием UEngine:

До этого момента, когда мы говорили про движок, имелся в виду движок со строчной буквы. Это исполняемый файл, который мы запускаем, состоящий из кода, который мы не писали. Теперь же настало время поговорить о Движке. 

Движок — это программный продукт, содержащий исходный модуль под названием Engine. В этом модуле находится заголовок Engine.h, а в этом заголовке определен класс UEngine, реализованный как в UEditorEngine, так и в UGameEngine.

На этапе инициализации игры FEngineLoop проверяет файл конфигурации движка, чтобы определить, какой класс GameEngine нужно использовать. Затем он создает экземпляр этого класса и закрепляет его как глобальный экземпляр UEngine, доступный через глобальную переменную GEngine, объявленную ​​в Engine/Engine.h.

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

Так что же делает класс Engine? На него возложено множество обязанностей, но основная заключается в этом наборе больших массивных функций, включающем Browse и LoadMap. Мы рассмотрели загрузку процесса и инициализацию всех систем движка, но для того, чтобы зайти в игру, нам нужно загрузить карту, и делается это при помощи класса UEngine.

Engine может просматривать URL-адрес, который представляет собой либо адрес сервера для подключения в качестве клиента, либо имя карты для локальной загрузки. В URL-адресах есть и свои добавляемые аргументы. Когда вы устанавливаете карту по умолчанию в файле DefaultEngine.ini вашего проекта, вы говорите движку автоматически переходить к этой карте при загрузке. Конечно, в своих сборках вы также можете переопределить карту по умолчанию, указав новый URL-адрес в командной строке, или можете использовать команду open для перехода на другой сервер или карту во время игры.

Итак, давайте посмотрим на инициализацию класса Engine. Она происходит перед загрузкой карты и делает это посредством создания нескольких важных объектов: GameInstance, GameViewportClient и LocalPlayer. Можно утрированно представить, что LocalPlayer — это представление пользователя, сидящего перед экраном, а GameViewportClient — это и есть сам экран: по сути, это высокоуровневый интерфейс для систем рендеринга, звука и ввода.

Класс UGameInstance был добавлен в Unreal 4.4 и выделен из класса UGameEngine для обработки некоторых функций, более специфичных для проекта, которые ранее обрабатывались в Engine.

Итак, после инициализации класса Engine у нас появляются GameInstance, GameViewportClient и LocalPlayer. Теперь игра готова к запуску: именно здесь происходит первоначальный вызов LoadMap. К концу вызова LoadMap у нас будет UWorld, содержащий всех актеров, сохраненных на нашей карте, а также несколько новых актеров, формирующих ядро ​​GameFramework, включающее игровой режим, игровую сессию, состояние игры, диспетчер игровой сети, контроллер игрока, состояние игрока и пешку. 

Одним из ключевых факторов, разделяющих эти два набора объектов, является время жизни. На высоком уровне есть два разных времени жизни: все, что происходит до загрузки карты, и все, что происходит после.

Все, что происходит до LoadMap, привязано ко времени жизни процесса. Все остальное — например, GameMode, GameState и PlayerController, — создается уже после загрузки карты и остается там до тех пор, пока вы играете на этой карте. Движок поддерживает так называемый seamless travel, когда вы можете переходить на другую карту, сохраняя при этом некоторых актеров со старой. Но если вы сразу перейдете к новой карте, или подключитесь к другому серверу, или вернетесь в главное меню, тогда все актеры будут уничтожены, мир очищен, и эти классы не будут отображаться, пока вы не загрузите другую карту.

Итак, давайте посмотрим на то, что происходит в LoadMap. Это сложная функция, но если мы вернемся ее к основам, то выясним, ее не так уж и тяжело понять.

Сначала движок запускает глобальный делегат, чтобы указать, что произойдут изменения карты. Затем, если карта уже загружена, она очищает и уничтожает игровой мир. Сейчас нас больше всего интересует инициализация, поэтому мы просто помашем ему ручкой. 

Короче говоря, к этому моменту World уже не будет. Однако у нас есть Контекст Мира (World Context). Этот объект создается экземпляром игры во время инициализации Engine, и по сути, это постоянный объект, который отслеживает, какой мир загружен в данный момент. Перед загрузкой чего-либо еще GameInstance может предварительно загрузить любые ассеты, которые ему могут понадобиться, но по умолчанию ничего не делает.

Далее нам нужно получить UWorld.

Если вы работаете с картой в редакторе, редактор загружает в память UWorld вместе с одним или несколькими ULevels, которые содержат размещенных вами Актеров. Когда вы сохраняете свой постоянный уровень, этот Мир, его Уровень и все его Актеры сериализуются в пакет карты, который записывается на диск в виде файла .umap. Во время LoadMap движок находит этот пакет и загружает его. На этом этапе мир, его постоянный уровень и актеры на этом уровне, а также WorldSettings загружаются обратно в память.

Теперь у нас есть World, и мы должны его инициализировать.

Движок дает миру ссылку на GameInstance, а затем инициализирует глобальную переменную GWorld. Затем мир устанавливается в WorldContext, ему присваивается тип — в данном случае Game, — и он добавляется в корневой каталог. InitWorld позволяет миру настраивать такие системы, как физика, навигация, искусственный интеллект и аудио.

Когда мы вызываем SetGameMode, мир просит GameInstance создать актера GameMode. Как только это происходит, движок полностью загружает карту — то есть, загружаются все подуровни вместе с ассетами.

Далее мы переходим к InitializeActorsForPlay. Это то, что Engine называет «подведением мира к игре». Здесь World перебирает всех актеров в нескольких разных циклах. Первый цикл регистрирует все компоненты актеров в мире. 

Происходит регистрация каждого компонента ActorComponent в каждом Actor, что делает для компонента три важных вещи:

  • Мы получаем ссылку на мир, в который он был загружен;

  • Затем происходит вызов функции компонента OnRegister, дающий ему возможность выполнить любую раннюю инициализацию;

  • В случае PrimitiveComponent после регистрации компонент будет иметь FPrimitiveSceneProxy, созданный и добавленный в FScene, являющийся версией потока рендеринга UWorld.

После регистрации компонентов World вызывает функцию InitGame GameMode. Это заставляет GameMode породить актера GameSession. После этого у нас есть еще один цикл, в котором мир проходит от уровня к уровню, и каждый уровень инициализирует всех своих актеров. Это происходит за два прохода. В первом проходе Уровень вызывает функцию PreInitializeComponents для каждого Актера. Это дает участникам возможность инициализироваться довольно рано — после регистрации компонентов, но до их инициализации.

GameMode — это такой же актер, как и любой другой, поэтому здесь также вызывается функция PreInitializeComponents. После этого GameMode порождает объект GameState и связывает его с миром, а также порождает GameNetworkManager, прежде чем, наконец, вызвать функцию InitGameState.

Наконец, мы повторяем цикл по всем актерам, на этот раз вызывая InitializeComponents, а затем — PostInitializeComponents. InitializeComponents перебирает все компоненты актеров и проверяет две вещи:

  • Если в компоненте включена функция bAutoActivate, необходимо активировать компонент;

  • Если в компоненте включен bWantsInitializeComponent, произойдет вызов функции InitializeComponent. 

PostInitializeComponents — это самая ранняя точка, в которой актер оказывается в полностью сформированном состоянии, так что это обычное место для размещения кода, который инициализирует актера в начале игры.

На этом этапе наш вызов LoadMap почти завершен: все актеры загружены и инициализированы, мир готов для запуска в игре, и теперь у нас есть набор актеров, используемых для управления общим состоянием игры: GameMode определяет правила игры, он же порождает большинство актеров кор-геймплея. Это высший авторитет того, что происходит во время игры, и он существует только на сервере. GameSession и GameNetworkManager также работают только на сервере. Сетевой менеджер используется для настройки таких вещей, как обнаружение читов и предсказание движения. А для онлайн-игр GameSession одобряет запросы на вход и служит интерфейсом для онлайн-сервисов (например, Steam или PSN).

GameState создается на сервере, и только сервер имеет право изменять его, но реплицируется он для всех клиентов: именно там вы храните данные о состоянии игры, которые, по вашему мнению, должны быть доступны всем игрокам.

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

В этот момент LoadMap перебирает все LocalPlayers, присутствующие в нашем GameInstance: обычно таковой существует только один. Для конкретного LocalPlayer он вызывает функцию SpawnPlayActor. Обратите внимание, что PlayActor здесь взаимозаменяем с PlayerController: эта функция порождает PlayerController. LocalPlayer, как мы уже убедились, является представлением игрока в движке, а PlayerController — представлением игрока в игровом мире.

LocalPlayer на самом деле является специализацией базового класса Player. Есть еще один класс Player под названием NetConnection, который представляет игрока, подключенного удаленно.

Чтобы любой игрок мог присоединиться к игре независимо от того, локальная она или удаленная, он должен пройти процесс входа в систему. Этот процесс обрабатывается GameMode. Функция PreLogin в GameMode вызывается только для попыток удаленного подключения: она отвечает за утверждение или отклонение запроса на вход. Как только мы получаем добро на добавление игрока в игру, происходит вызов Login. Функция Login порождает актера PlayerController и возвращает его в мир.

Конечно, поскольку мы создаем актера после того, как создали мир, этот актер инициализируется при своем появлении. Это означает, что происходит вызов функции PostInitializeComponents нашего PlayerController, которая, в свою очередь, порождает актера PlayerState.

PlayerController и PlayerState похожи на GameMode и GameState в том, что один из них является официальным представлением игры (или игрока) на сервере, а второй содержит данные, которые каждый должен знать об игре (или игроке). 

После создания PlayerController World полностью инициализирует его для работы в сети и связывает с объектом Player. После этого вызывается функция PostLogin игрового режима, которая дает игре возможность выполнить любую настройку, которая должна произойти в результате присоединения этого игрока. По умолчанию игровой режим будет пытаться создать Pawn для нового PlayerController в PostLogin. Pawn — это особый тип актера, которым может владеть Controller. PlayerController — это специализация базового класса Controller. Есть еще один подкласс под названием AIController, использующийся для неигровых персонажей.

Это давнее соглашение в Unreal: если у вас есть актер, который перемещается по миру, руководствуясь собственным автономным процессом принятия решений — будь то игрок-человек, принимающий решения и переводящий их в данные ввода, или ИИ, принимающий решения более высокого уровня о том, куда идти и что делать, — обычно у вас есть два актера. Controller — это тот, кто управляет актером, а Pawn — представление актера в мире. Поэтому, когда к игре присоединяется новый игрок, GameMode по умолчанию порождает Pawn для нового PlayerController.

Ваш PlayerState может быть настроен так, чтобы игрок был простым наблюдателем без возможности взаимодействия с миром, или вы можете настроить GameMode, чтобы изначально все игроки были только зрителями. В этом случае GameMode не будет порождать Pawn, а вместо этого PlayerController создаст свой собственный SpectatorPawn, позволяющий следить за происходящим в игре, не взаимодействуя с игровым миром.

В противном случае при PostLogin игровой режим выполнит то, что называется «перезапуском игрока». Для примера возьмем мультиплеерный шутер: если игрока убивают, его Pawn оказывается мертва: она ​​больше не находится под контролем игрока — он просто висит как труп, пока не произойдет перезапуск игры. Но PlayerController все еще продолжает существовать, и когда игрок захочет возродиться, игра должна создать для него новую Pawn. Вот что делает RestartPlayer: при наличии PlayerController он найдет актера, представляющего, где должна быть создана новая Pawn, затем определит, какой класс Pawn нужно использовать, и создаст экземпляр этого класса.

По умолчанию игровой режим просматривает всех актеров PlayerStart, размещенных на карте, и выбирает одного из них. Но такое поведение можно переопределить и настроить в классе GameMode.

В любом случае, после создания Pawn будет связана с PlayerController, которому она принадлежит. Теперь, когда мы вернемся в LoadMap, у нас будет все готово для фактического запуска игры. Все, что осталось сделать — это маршрутизировать событие BeginPlay. От Engine к World, от World к GameMode, от GameMode к WorldSettings, а WorldSettings, в свою очередь, перебирает всех актеров.

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

В качестве повторения быстро пробежимся по всему еще раз:

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

Сейчас нас больше всего беспокоит то, что происходит во время инициализации. Первой точкой, в которой запускается код вашего проекта или плагина, будет загрузка вашего модуля. Она может произойти в нескольких точках, в зависимости от LoadingPhase, но обычно выбирается момент ближе к концу PreInit.

Когда завершается загрузка модуля, происходит регистрация всех классов UObject, а объекты по умолчанию инициализируются через конструктор. Затем вызывается функция StartupModule вашего модуля, и это первое место, где вы можете подключиться к делегатам, чтобы настроить другие функции, вызов которых произойдет позже.

На этапе инициализации мы начинаем настройку самого движка. Короче говоря, мы создаем объект Engine, инициализируем его, а затем запускаем игру. Чтобы инициализировать движок, мы создаем GameInstance и GameViewportClient, а затем создаем LocalPlayer и связываем его с GameInstance. После этого мы можем начать загрузку игры.

Мы выясняем, какую карту будем использовать, переходим к этой карте и сообщаем GameInstance о том, что сделали. 

Остальная часть нашего процесса запуска происходит в вызове LoadMap. Сначала мы находим наш пакет карты, затем загружаем его: он переносит в память всех актеров, помещенных на постоянный уровень, а также дает нам объекты World и Level. Мы находим наш мир, даем ему ссылку на GameInstance, инициализируем некоторые системы, а затем создаем актера GameMode. После этого мы полностью загружаем карту, добавляя все необходимые подуровни и ассеты. Когда все оказывается полностью загружено, мы начинаем подходить мир к игре. Сначала мы регистрируем все компоненты для каждого актера на каждом уровне, а затем инициализируем GameMode, который, в свою очередь, порождает актера GameSession. После этого мы инициализируем всех актеров в мире.

Сначала мы вызываем PreInitializeComponents для каждого актера на каждом уровне: когда это происходит для GameMode, он порождает GameState и GameNetworkManager, а затем инициализирует GameState. Затем в другом цикле мы инициализируем каждого актера на каждом уровне: происходит вызов InitializeComponent (и, возможно, Activate) для всех компонентов, которым это необходимо, после чего актеры уже формируются окончательно.

Как только мир будет готов к запуску в игре, мы можем зарегистрировать LocalPlayer внутри игры. Здесь мы создаем PlayerController, который, в свою очередь, порождает для себя PlayerState и добавляет это PlayerState в GameState. 

Затем мы регистрируем игрока в GameSession и кэшируем начальную точку старта. Создав PlayerController, мы можем инициализировать его для работы в сети и связать с нашим LocalPlayer. Затем мы переходим к PostLogin, где, предполагая, что все необходимые настройки произведены, мы можем перезапустить игрока, что означает, что мы выясняем, где его точка старта в мире. Выясняем, какой класс Pawn использовать, а затем вызываем и инициализируем его. Помимо Pawn, у нас еще есть PlayerController, ей владеющий, и можно установить значения по умолчанию для этой Pawn.

Наконец, все, что нам остается сделать, — это маршрутизировать событие BeginPlay. В результате BeginPlay вызывается для всех актеров в мире, который регистрирует тиковые функции и вызывает BeginPlay для всех компонентов, после чего, наконец, именно в этой точке запускается событие BeginPlay Blueprint. На этом мы заканчиваем загрузку карты, официально запускаем игру и завершаем этап инициализации нашего цикла движка.

Мы рассмотрели здесь много вещей, поэтому выделим самое важное:

  • Мы рассматривали классы GameModeBase и GameStateBase, а не GameMode и GameState. Эти базовые классы были добавлены в Unreal 4.14, чтобы исключить некоторые функциональные возможности Unreal-Tournament из игрового режима. В то время как GameModeBase содержит все основные функции игрового режима, класс GameMode добавляет концепцию «матча» с изменениями состояния матча, которые происходят после BeginPlay. Это позволяет следить за потоком игры — например, за готовностью всех игроков, за временем начала и окончания игры и переходом к новой карте для следующего матча.

  • Мы также рассмотрели класс Pawn, но помимо него GameFramework определяет класс Character, который является специализированным типом Pawn, включающим сразу несколько полезных функций. У класса Character есть капсула столкновения, которая используется в основном для движений, а также скелетная сетка, в связи с чем предполагается, что он является анимированным персонажем. Еще у него есть компонент CharacterMovementComponent, тесно связанный с классом Character и выполняющий несколько полезных вещей. Самым важным является то, что движение персонажа воспроизводится из коробки с предсказанием движения на стороне клиента. CharacterMovement реализует полный набор опций передвижения для ходьбы, прыжков, падений, плавания и полета.

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

Итак, вот все те классы, которые мы рассмотрели (за исключением UWorld и ULevel):

Мы убедились, что у Unreal есть зрелая Game Framework, которая имеет устоявшийся дизайн для обработки таких вещей, как онлайн-интеграция, запросы на вход и репликация сети. Это означает, что вы можете довольно легко разрабатывать многопользовательские игры из коробки, и движок позволяет добавлять пользовательские функции практически на любом уровне. Если вас больше всего интересует создание простых однопользовательских игр, то столь комплексная Game Framework может оказаться для вас довольно бесполезной.

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

Может показаться, что в работе движка нужно много разбираться, но ознакомиться с этими дизайнерскими решениями все-таки полезно, чтобы иметь их в виду на случай, когда вам понадобится с ними работать.

Tags:
Hubs:
Total votes 21: ↑21 and ↓0+21
Comments2

Articles

Information

Website
pixonic.com
Registered
Founded
Employees
201–500 employees
Location
Кипр