Social gaming с Playtika. Как мы разрабатывали собственный движок для слот-игры

30 июня 2016, 11:15

В сегодняшней публикации совместного проекта dev.by и компании Playtika инженер-программист Антон Лобанов рассказывает, как минская команда крупнейшего в мире разработчика социальных казино работала над созданием собственного игрового движка Monosyne.

Читать далее

У многих C# и .NET ассоциируется с web, enterprise и различными десктопными приложениями, использующими WinForms или WPF. Поэтому когда руководство поставило перед нами задачу написать движок для мобильных платформ на C#, у нас, признаться честно, были сомнения относительно успеха этой затеи. Бизнес хотел получить инструмент, который бы позволил сделать порт игры с Action Script, используя Adobe Flash как редактор для создания сцен и анимации. По сути, некоторое подобие ScaleForm.

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

Возможности

Поскольку ресурсы будущих игр должны были готовиться в AdobeFlash, нам предстояло написать инструмент, который бы поддерживал максимальное количество его возможностей. Это было задачей №1.

Впоследствии мы добавляли возможности, которых нет в AdobeFlash, дописывали собственные компоненты в редактор (например, редактор частиц). Задача  разделилась на две большие части: движок с определённым форматом ресурсов, поддерживающим все возможности Flash, и некий конвертер из Adobe .fla в наш собственный формат.

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

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

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

AbstractNode node = ContentManager.Load<AbstractNode>("scene3x2.object");
ControlNode controlNode = node.FindById<ControlNode>("rootControlNode");
controlNode.StateMachine.SendEvent(new ParamEvent<string>(“start"));

Так выглядит сцена в редакторе Adobe Flash.

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

Движок не навязывает архитектуру приложения, инструментарий и не накладывает на программиста никаких ограничений. Можно использовать весь арсенал C# (Task’и, async/await, Linq и т.д.), а также любые профайлеры (для .NET, DirectX, OpenGL).

А теперь подробнее остановимся на некоторых технических моментах.

Managed <-> Native

В любом движке есть модули по декодированию звуков, распаковке ресурсов. В нашем случае также нужно было декодировать видео, много видео. Понятно, что писать все библиотеки которые были нам нужны мы не собирались. Попробовав несколько решений написанных на C#, и посмотрев на их производительность, стало понятно: без использования нативных dll, написанных на C/C++, мы никуда не придём.

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

[DllImport(“some.dll”, CallingConvention = CallingConvention.Cdecl, EntryPoint = "somefunc")]
public static extern ReturnCode SomeFunc();

(Стоит сказать что на платформе Windows Phone 8.0 механизм DllImport отсутствует, но это уже другая история, да и платформа уже не актуальна).

… то с обратным вызовом есть некоторые моменты.

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

public abstract class NativeObject: Disposable
    {
        public readonly IntPtr Handle;

        protected NativeObject()
        {
            Handle = GCHandle.ToIntPtr(GCHandle.Alloc(this));
        }

        public static T Cast<T>(IntPtr pointer)
        {
            return (T)GCHandle.FromIntPtr(pointer).Target;
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                GCHandle.FromIntPtr(Handle).Free();
            }
        }
    }

То есть, если мы хотим фактически передать некий класс в неуправляемый код, чтобы потом его использовать в обратном вызове, наследуем наш класс от NativeObject и передаём в качестве параметра поле Handle данного класса. А потом просто в статической функции обратного вызова делаем, скажем, для вымышленного класса IOSubSystem так:

IOSubSystem io = NativeObject.Cast<IOSubSystem>(handle);

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

byte[] buffer = new byte[2048];
int bytes = stream.Read(buffer, 0, 2048);
Marshal.Copy(buffer, 0, pointer, bytes);

То есть, делаем лишнее копирование в массив buffer. К сожалению, мы так и не нашли способ читать из .Net Stream напрямую в IntPtr. Конечно, можно было реализовать полностью свой IO в неуправляемом коде, но это совсем неудобно.

SharpDX и OpenTK

Ни для кого не новость, что современные мобильные устройства укомплектованы графическими ускорителями, которые лет 15 назад и в десктопах не снились. Для работы с ними есть OpenGL, DirectX, Metal, Vulkan… Но, к счастью, все они написаны не на C# )). И если бы не SharpDX и OpenTK, нам пришлось бы писать свои биндинги на эти библиотеки, используя механизмы, о которых мы рассказали в предыдущем абзаце. Но мир не без хороших людей, и всё уже написано до нас.

SharpDX представляет собой библиотеку С# с полным DirectX API 11 и 12. Написан он очень неплохо, проблем с ним особо не возникало. Так же там есть XAudio API для работы со звуком.

OpenTK — это такая же библиотека, но предоставляющая OpenGL и OpenAL API. В ней также содержатся классы, которые должны помочь организовать GameLoop на различных платформах (AndroidGameView, IPhoneGameView и т.д), но вот их мы использовать не советуем. Написаны они неважно, и быстро исправить присутствующие там ошибки не представляется возможным.

Внимательный читатель спросит, что мы используем в качестве звукового бэкенда на Android. К сожалению, приличного биндинга на OpenSL API не нашлось, поэтому его пришлось написать самим.

Shaders

Итак мы научились работать с DirectX и OpenGL из С# и, конечно, сразу же захотелось что-нибудь нарисовать на экране мобильного устройства. Все знают что есть языки высокого уровня для программирования шейдеров. HLSL — для DirectX и GLSL — для OpenGL. Было решено писать шейдерные программы для каждого графического бэкенда отдельно для достижения максимальной оптимизации, да и вообще это просто интересно.

Мы использовали подход, называемый UberShader — это когда из одного исходника собираются различные варианты шейдерных программ с помощью #define’ов. На данный момент у нас порядка 230 программ. Собираем мы их на лету прямо на устройстве, когда возникает такая необходимость. Над каждым исходником шейдерной программы есть его объектное представление в виде C#-класса, который уже используется в движке. С помощью его можно устанавливать параметры шейдерной программы и т.п. Например:

public interface IBlurProgram : IProgram
    {
        Vector2D BlurSize { set; }
    }

Реализация этого класса под OpenGL передаст данные в программу через uniform, под DirectX — через constant buffer.

.NET Collections

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

И мы сразу попробовали использовать весь System.Collections.Generic, а также System.Linq. И тут нас ждало второе разочарование: они не такие быстрые, как нам бы хотелось, а Linq в принципе порождает кучу аллокаций, соответственно и вызовов GC. Единственное, что работает «на отлично» — это массивы. Поэтому во всех критических местах мы используем только их. Правда, и тут есть небольшие нюансы. Например, при очистке массива, который хранит reference type, нужно занулять все элементы, чтобы потерять ссылки на них и избежать memory leaks, либо пересоздать массив заново. И тут надо выбирать — создание нового массива дорого, но зануление скажем 10 000 элементов может оказаться ещё дороже.

В остальных не особо критичных местах прекрасно подходят коллекции из System.Collection.Generic

Потоки

Ну а куда же без них…

В движке у нас создаются потоки для следующих целей:

  1. GameRenderLoop —  основной поток игрового цикла, где происходит отрисовка.
  2. Loading Thread — поток со вторым графическим контекстом для загрузки графических ресурсов для последующего использования их на основном потоке. Нужен, чтобы сохранить плавность игры во время загрузки, например, следующего уровня.
  3. Декодирование аудиостримов.
  4. Декодирование видеостримов.

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

С DirectX в общем-то проблем нет — главное не использовать ImmediateContext на другом потоке. В остальном всё отлично, мы можем легко создавать текстуры, а потом использовать их в основном потоке.

С OpenGL сложности есть и их набор напрямую зависит от драйверов.

Чтобы создавать ресурсы OpenGL на некотором потоке, для него нужно создать EGLContext с использованием метода eglCreateContext и сделать его текущим eglMakeCurrent. Для этого, как оказалось, обязательно нужно создать второй EGLSurface размером хотя бы 1x1 пиксель (методом eglCreatepBufferSurface) и передать его в качестве параметров вызову eglMakeCurrent — иначе вызов на некоторых драйверах возвращает ошибку. Собственно ошибку он может вернуть, если SharedContext не поддерживается, и тогда нужно использовать механизм загрузки через основной поток.

Так же при создании ресурсов на другом потоке обязательно нужно выполнить функцию glFlush.

Примерно так должен выглядеть код создания текстуры:

GL.GenTextures(1, out TextureId);
GL.BindTexture(TextureTarget.Texture2D, TextureId);
GL.TexImage2D(TextureTarget.Texture2D, 0, (PixelInternalFormat) _pixelFormat, Width, Height, 0, 
_pixelFormat, _pixelType, data);
GL.BindTexture(TextureTarget.Texture2D, 0);
GL.Flush();

SynchronizationContext и Async/Await

C# предоставляет отличную инфраструктуру для работы с потоками. По сути, сами потоки использовать и не придётся, поскольку есть TPL. Что бы вся эта связка работала с вашим потоком, нужно написать свой SynchronizationContext, сделать его текущим в вашем потоке — и готово. Можно «маршалить» вызовы в ваш поток через SynchronizationContext, использовать async/await, а также в дополнение — написать свой механизм поверх SynchronizationContext, что мы и сделали.

Вот как, например, выглядит код загрузки сцены в потоке из ThreadPool в Monosyne:

// вызов сделался на основном потоке
protected override async void OnInitialize()
        {           
	await SwitchContext.To(TaskScheduler.Default); // переключаемся на поток из ThreadPool

            AbstractNode node = ContentManager.Load<AbstractNode>("scene3x2");// грузим сцену
            node.Initialize(); // инициализируем ее

            await SwitchContext.To(Game); // возвращаемся на основной поток

            AddComponent(node); // добавляем сцену в работающую игру… все ))
        }

ConditionalWeakTable и Disposable

Не будем рассказывать об интерфейсе IDisposable — об этом можно почитать на MSDN. Но хотелось бы остановиться на моменте, когда вам необходимо пошарить IDisposable-объект нескольким сущностям. Хорошо, когда можно чётко определить владельца и время жизни такого объекта. Однако когда это невозможно, на помощь приходит паттерн RefCounter. Реализовать его в .NET помогает класс ConditionalWeakTable, позволяющий как бы добавлять поля в объект в runtime. Этот класс хранит в качестве ключа Weak Reference на нужный вам объект, а в качестве значения — любой класс. В нашем случае это класс, который хранит значение счётчика ссылок:

internal class RefCount
    {
        public int ReferenceCount;
    }

Ну а дальше, используя методы расширения, очень легко реализовать нужный нам функционал:

public static void AddReference(this IDisposable disposable)
        {
            if (disposable != null)
            {
                    RefCount refCount = RefCounts.GetOrCreateValue(disposable);
                    refCount.ReferenceCount++;
            }
        }

        public static void Release(this IDisposable disposable)            
        {
            if (disposable != null)
            {
                    RefCount refCount = RefCounts.GetOrCreateValue(disposable);
                    refCount.ReferenceCount--;
                    if (refCount.ReferenceCount <= 0)
                    {
                        RefCounts.Remove(disposable);
                        disposable.Dispose();
                    }
            }
        }

К слову сказать, методы расширения мы используем довольно часто. Это одна из возможностей C#, которой не хватает в C++.

Dynamic Batching

Все знают, как не любят Batch или так называемые DrawCalls современные графические API и драйверы. Естественно, нужно попытаться минимизировать их количество. Мы решили не взваливать заботу о батчах на плечи программистов, использующих движок. Система динамического менеджмента батчей заложена в самом ядре. Отслеживается тип вершин, текстура, состояния, параметры шейдерной программы, количество примитивов (если, например, нужное вам количество не влазит в текущий батч).

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

protected override void OnRenderFrame(GameTime time, RenderSupport renderSupport)
        {
	BatchStateManager batchStateManager = renderSupport.BatchStateManager;
            batchStateManager.PushStates(renderSupport.BlendStatesManager.AlphaBlend, Effect);

IQuadRenderBuffer renderBuffer = batchStateManager.UseQuadRenderBuffer(VertexPosition2DColorTexture.IntType);
            renderBuffer.PrepareBuffer(texture, 100); // готовим буффер на 100 quad’ов
	        IntPtr pointer = renderBuffer.AllocBuffer(100); // получаем указатель на видео память
	        for (int i = 0; i < 100; i++)
	        {
	            VertexPosition2DColorTexture* p = (VertexPosition2DColorTexture*) pointer;
                        // ну а тут пишем по указателю, что нам надо
	        }


	        batchStateManager.PopStates();
        }

Dynamic batching работает по принципу стека. То есть вы делаете Push нужных вам состояний и контекста шейдерной программы. После отрисовки делаете Pop — остальное система выполняет сама. Если состояние, текстура, параметры не меняются, и нужное количество примитивов помещается в текущий буфер, то всё попадает в один батч.

Естественно, такой низкоуровневый код пишется редко, в основном все используют высокоуровневые рендереры или Scene graph, в которых всё это реализовано.

Вот так для сравнения может выглядеть код отрисовки объекта SpriteNode:

public void Draw(GameTime time, RenderSupport renderSupport)
        {
            SpriteRenderer.Draw(TransformModel.worldPosition,
                                TransformModel.origin,
                                TransformModel.worldTransform,
                                TransformModel.worldColor,
                                renderSupport.QuadBatch);

Reflection

В .NET, конечно, же есть мощнейший механизм для получения в Runtime всей необходимой информации об объекте (свойства, методы, информация о типе и т.д.). К сожалению, он не слишком быстрый, и мы по привычке написали свой. Все анимации в сцене работают через установку свойств объекта. Анимаций одновременно может быть крайне много и, конечно же, всё это должно работать максимально быстро. Связь конкретного свойства объекта с action, который будет его менять, происходит во время построения сцены на этапе её загрузки. Накладные расходы на установку значения свойства получились небольшие — всего один лишний вызов. Единственный минус — всё нужно регистрировать. Так регистрируются свойства для конкретного типа AbstractNode. Как можно заметить, метод статический, соответственно и вызывается всего один раз:

protected new static PropertyMap RegisterProperties(PropertyMap map)
        {
            map.Register("Angle",
                         (AbstractNode o, float v) => o.TransformModel.Angle = v,
                         (AbstractNode o) => o.TransformModel.Angle);
            map.Register("Color",
                         (AbstractNode o, Color v) => o.TransformModel.Color = v,
                         (AbstractNode o) => o.TransformModel.color,
                         Color.White);
            map.Register("Origin",
                         (AbstractNode o, Vector2D v) => o.TransformModel.Origin = v,
                         (AbstractNode o) => o.TransformModel.origin);
            map.Register("Position",
                         (AbstractNode o, Vector2D v) => o.TransformModel.Position = v,
                         (AbstractNode o) => o.TransformModel.position);
            map.Register("Scale",
                         (AbstractNode o, Vector2D v) => o.TransformModel.Scale = v,
                         (AbstractNode o) => o.TransformModel.scale,
                         Vector2D.One);
            map.Register("Skew",
                         (AbstractNode o, Vector2D v) => o.TransformModel.Skew = v,
                         (AbstractNode o) => o.TransformModel.skew);

            return GameComponent.RegisterProperties(map);
        }

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

T4 Code generation

T4 представляет собой довольно мощный механизм генерации текстовой информации на базе C#. Мы решили использовать эту возможность для генерации кода систем частиц (Particles). Фактически, мы использовали тот же UberShader-подход, но для частиц. В генерированном коде присутствует только то, что необходимо для достижения нужного поведения системы. В самой частице присутствуют только те поля, которые нужны для достижения нужных целей. В результате никаких проверок во время жизни системы, отсутствие лишних вычислений, минимальный размер частицы и, соответственно, хорошая производительность.

Bip — binary package

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

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

Такой подход позволил просто отражать в память все данные без какого-либо разбора. Обращение по индексам происходит мгновенно. Построение сцен из ресурсов такого формата местами стало быстрей на порядок по сравнению с Json.

Вот как выглядит структура ресурса строки:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
    public struct StringData
    {
        public readonly Int32 Length; // длина строки
        public readonly Int32 Offset; // смещение от начала секции с char’ами
    }

Причём такой подход позволяет использовать одни и те же данные для разных ресурсов. Например, в секции с символами лежит “Press Button for Procceed”. Очевидно, что с помощью Length и Оffset мы можем получить строки “Press”, “Button”, “Press Button” и т.д.

Ну а так, например, выглядит ресурс спрайта:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
    public struct SpriteSheetData
    {
        public readonly UInt32 NameIndex; // индекс в секцию строк
        public readonly UInt32 TextureNameIndex;// индекс в секцию строк
        public readonly UInt32 FramesIndicesIndex;// индекс в секцию массивов Int’ов, 
в котором хранятся индексы в секцию массива с кадрами (запутанно, но быстро)
    }

Как видно, все ресурсы SpriteSheetData — одного размера, хотя содержат разные имена и разное число кадров.

Выводы

После проделанной работы можно с уверенностью утверждать: останавливаться мы точно не собираемся. Теорема существования доказана. Если вы не собираетесь писать игру класса AAA, то использовать C# очень даже можно.

Одна неприятная деталь, о котором хочется упомянуть напоследок, — это Garbage Collector. Поскольку полностью избавиться от аллокаций практически невозможно (особенно в коде игры, а не движка), то GC будет срабатывать, а мир — останавливаться: и у нас из 16.66 миллисекунд кадра будет выпадать добрый кусок, а он иногда так нужен.

Также читайте в проекте: 

 

подписка на главные новости 
недели != спам
# ит-новости
# анонсы событий
# вакансии
Обсуждение