В сегодняшней публикации совместного проекта 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
Потоки
Ну а куда же без них…
В движке у нас создаются потоки для следующих целей:
- GameRenderLoop — основной поток игрового цикла, где происходит отрисовка.
- Loading Thread — поток со вторым графическим контекстом для загрузки графических ресурсов для последующего использования их на основном потоке. Нужен, чтобы сохранить плавность игры во время загрузки, например, следующего уровня.
- Декодирование аудиостримов.
- Декодирование видеостримов.
Про загрузку графических ресурсов, то есть текстур, на отдельном потоке можно написать целую статью, мы же остановимся на основных моментах, с которыми столкнулись.
С 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 миллисекунд кадра будет выпадать добрый кусок, а он иногда так нужен.
Также читайте в проекте:
- Как крупнейший разработчик социальных казино вырос в 15 раз за 5 лет
- Логирование как главный инструмент техподдержки
- Как мы писали одну из крупнейших платформ для слот-машин
- Свой движок для слот-игры: блажь или необходимость?
Релоцировались? Теперь вы можете комментировать без верификации аккаунта.