[Из песочницы] Нюансы разработки плагина под Unity

Недавно столкнулся с написанием плагинов под Unity. Опыта раньше не было, да и пользователем данной среды являюсь всего 2–3 месяца. За время разработки плагина накопилось очень много интересных моментов, о которых в интернете мало информации. Хочу все эти моменты описать подробнее для первопроходцев, чтобы они не попадали на те же самые грабли, на которые я сам наступал много и много раз.

Данная статья также должна быть полезна и опытным пользователям. В ней будет рассмотрен полезный инструментарий и нюансы разработки плагинов под OSX, Windows, iOS и Android.
С проигрыванием видео в Unity с давних пор не все хорошо сложилось. Встроенные инструменты очень ограничены, а на мобильных платформах они могут проигрывать видео только в полноэкранном режиме, что для геймдева не айс! Вначале мы использовали сторонние плагины. Однако там либо не хватало нужного функционала, либо были баги, фиксы которых приходилось долго ждать (если их вообще фиксили). По этой причине решили написать свою версию видеодекодера для Unity с блекджеком и ш…, стоп, и с фичами.

Само создание плагина и код выкладывать не буду — пардон, коммерческая тайна, а на общих принципах остановлюсь. Для реализации видеодекодера взял кодеки vp8 и vp9, которые могут проигрывать открытый и не требующий лицензионных отчислений формат WebM. После декодирования видеокадра получаем данные в цветовой модели YUV. Затем каждый компонент пишем в отдельную текстуру. По сути, на этом работа плагина заканчивается. Дальше в самом Unity шейдер декодирует YUV в цветовую модель RGB, которую уже и применяем к объекту.

Вы спросите — почему шейдер? Хороший вопрос. Сначала пробовал конвертировать цветовую модель софтварно, на процессоре. Для десктопов это приемлемо, да и производительность особо не падает, а вот на мобильных платформах картина кардинально отличается. На iPad 2 в рабочей сцене  софтовый конвертер давал 8–12 FPS. При цветовой конвертации в шейдере получали 25–30 FPS, что уже является нормальным играбельным показателем.

Перейдем собственно к нюансам разработки плагина.


Документация по написанию плагинов для Unity довольно скудная, все описывается в общих чертах (для iOS многие нюансы сам находил опытным путем). Ссылка на доку.

Что радует — есть примеры, собранные под актуальные студии и платформы (кроме iOS: наверное, Apple не доплатила разработчикам). Сами примеры обновляются с каждым обновлением Unity, но есть и ложка дегтя: часто меняются API, интерфейсы,  переименовываются дефайны и константы. К примеру, взял свежий апдейт, откуда использовал новый хидер. Потом долго разбирался, почему плагин не работает на мобильных платформах, пока не заметил:

SUPPORT_OPENGLES  // было
SUPPORT_OPENGL_ES // стало


Наверное, единственный важный момент для всех платформ, который нужно сразу учесть, — цикл отрисовки. Рендеринг в Unity может выполняться в отдельном потоке. Это значит, в основном потоке работать с текстурами не получится. Для разрешения данной ситуации в скриптах есть функция IssuePluginEvent, которая в нужный момент дергает callback, где должна быть выполнена работа с ресурсами, нужными для отрисовки. При работе с текстурами (создание, апдейт, удаление) рекомендую использовать корутину, котороя будет дергать callback в конце кадра:

private IEnumerator MyCoroutine(){
     while (true) {
            yield return new WaitForEndOfFrame();
            GL.IssuePluginEvent(MyPlugin.GetRenderEventFunc(),magicnumber);
     }
} 


Что интересно, если пытаться работать с текстурами в основном потоке, то игра падает только на DX9 API, да и то не всегда.
Наверное, самая простая и беспроблемная платформа. Плагин собирается быстро, дебажить тоже легко. В xCode делаем attach to Process → Unity. Можно ставить бряки, смотреть callstack при падении и т. д.

Был всего один интересный момент. Недавно Unity обновился до версии 5.3.2. В редакторе основным графическим API стал OpenGL 4, в более старой версии был OpenGL 2.1, который сейчас deprecated. В обновленной версии редактор просто не проигрывал видео. Быстрый дебаг показал, что функция glTexSubImage2D (GL_TEXTURE_2D, 0, 0, 0, width, height, GL_ALPHA, GL_UNSIGNED_BYTE, buffer) возвращает ошибку GL_INVALID_ENUM. Судя по документации OpenGL, на замену формату пикселей  GL_ALPHA пришел GL_RED, который не работает с OpenGL 2.1… Пришлось подпереть костылем:

const GLubyte * strVersion = glGetString (GL_VERSION);
m_oglVersion = (int)(strVersion[0] – '0');
if (m_oglVersion >= 3)
   pixelFormat = GL_RED;
else
   pixelFormat = GL_ALPHA;


А самое загадочное то, что в конечном билде, собранном под OpenGL 4, все отлично работает с флагом GL_ALPHA. Записал этот нюанс в раздел магии, но все же сделал по-человечески.

Unity Editor можно запустить на более старой версии  OGL. Для этого в консоли пишем:

Applications/Unity/Unity.app/Contents/MacOS/Unity -force-opengl

Из полезных утилит хочу отметить OpenGL Profiler, который входит в состав Graphics Tools. Тулзы можно скачать на сайте Apple в Developer разделе. Профайлер полностью позволяет отслеживать состояние OpenGL в приложении, можно отлавливать ошибки,  смотреть содержимое текстур (размер, тип, формат), шейдеров и буферов в видеопамяти, ставить breakpoints на разные события. Очень полезный инструмент для работы с графикой. Скриншот:

7d358ec0380f45408fb32a5529acba17.png

Так я узнал что в Unity Editor используется 1326 текстур.


На этой платформе  OpenGL версия плагина тоже собралась без каких-либо проблем. А вот на DirectX 9 остановлюсь поподробнее.

1. DirectX 9 обладает такой «фичей», как потеря устройства (lost device). OpenGL и DirectX (начиная с 10-й версии) лишены данного недостатка. Фактически происходит утрата контроля над графическими ресурсами (текстуры, шейдера, мэши в видеопамяти и т.д.). Получается, что мы должны обрабатывать эту  ситуацию, и если она произошла, то обязаны загрузить или создать все текстуры заново. По моим наблюдениям во многих плагинах именно так и делают. Мне удалось немного схитрить: текстуры я создаю со скриптов Unity, а потом передаю их указатели в плагин. Таким образом весь менеджмент ресурсов я оставляю  Unity, и он сам отлично справляется с ситуацией потери устройства.

MyTexture = new Texture2D(w,h, TextureFormat.Alpha8, false); 
MyPlugin.SetTexture(myVideo, MyTexture.GetNativeTexturePtr()); 


2. Когда, казалось, уже все было готово, обнаружилась неожиданная проблема. Иногда и только на некоторых видео картинка выводилась со смещением, как показано на скриншоте:

2b057d61b1f1464aa2f91c4d33d11b5c.png

Судя по виду изображения, ошибка могла присутствовать в алгоритме копирования данных в текстуру, в текстурных координатах или была связана с wrap-ом текстур. Документация подсказала, что DirectX для оптимизации может выравнивать размеры текстуры, добавляя дополнительные байты. Эта информация хранится в структуре:

struct D3DLOCKED_RECT {
   INT  Pitch;
   void *pBits;
}


Pitch — количество байт в одном ряду текстуры с учетом выравнивания.
Немного подправив алгоритм копирования, получил нужный результат (добавочные пиксели заполнил нулями):

for (int i = 0; i < height; ++i)
{
   memcpy(pbyDst, pbySrc, sizeof(unsigned char) * width);
   pixelsDst += locked_rect.Pitch;
   pixelsSrc += width;
}


Для отладки OpenGL поможет утилита gDEBugger, которая по функционалу схожа с OpenGL Profiler для OSX:

8f86b5a6d88740d580e691b9fef6b0cf.gif

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


Примера проекта под эту платформу в семплах не было. В документации мало полезной информации и в основном только о доступе к функциям с плагина.

Остановлюсь на важных аспектах:

1. В xCode создаем обычный iOS проект с типом StaticLib. Подключаем OpenGL фреймворки — и можно собирать плагин.

2. Имя конечного файла плагина не имеет значения. В Unity функции импортируются со всех плагинов, которые находятся в папке iOS:

[DllImport("__Internal")]


3. Важный момент — если у вас в другом плагине есть функция с одинаковым именем, то собрать билд не получится. Линковщик Unity будет материться на двойную имплементацию. Совет — именуйте так, чтобы до такого названия никто не додумался.

4. UnityPluginLoad (IUnityInterfaces* unityInterfaces), которая должна вызываться при загрузке плагина, не вызывается! Чтобы узнать, когда все же плагин стартанул и получить информацию о текущем рендер устройстве, нужно создавать свой контроллер, унаследованный от UnityAppController и в нем зарегистрировать вызов функций для старта плагина и RenderEvent. Созданный файл следует поместить в папку с плагинами для iOS. Пример реализации контроллера для регистрации функций:

#import 
#import "UnityAppController.h"

extern "C" void MyPluginSetGraphicsDevice(void* device, int deviceType, int eventType);
extern "C" void MyPluginRenderEvent(int marker);

@interface MyPluginController : UnityAppController
{
}
- (void)shouldAttachRenderDelegate;
@end

@implementation MyPluginController

- (void)shouldAttachRenderDelegate;
{
UnityRegisterRenderingPlugin(&MyPluginSetGraphicsDevice, &MyPluginRenderEvent);
}
@end

IMPL_APP_CONTROLLER_SUBCLASS(MyPluginController)


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

lipo -arch armv7 build/libPlugin_armv7.a\
-arch i386 build/libPlugin _i386.a\
-create -output build/libPlugin .a

6. В ходе тестирования обнаружил, что отрицательные текстурные координаты не передаются с вершинного шейдера в пиксельный — всегда приходят нули. По умолчанию текстуры создаются с режимом адресации CLAMP_TO_EDGE. В этом случае OpenGL ES все обрезает к диапазону [0…1]. В десктопных платформах такое не наблюдается.

7. Был замечен серьезный баг. Если собрать проект под iOS с включенным Script Debugging, то при креше в игре падает и xCode. В результате ни логов, ни callstack…

Дебажить плагин под iOS платформу одно удовольствие — в xCode всегда есть callstack при падении. В консоли можно почитать логи как скриптов, так и плагина, а если в проект добавить *.CPP файлы плагина, то можно ставить бряки и пользоваться полным функционалом lldb дебагера! А вот со скриптами все намного хуже, так что логгирование в помощь.


Сборка под Android требует больше всего тулзов:

— нормальный IDE для редактирования С++ кода. Я использовал xCode. Да и вообще на маке под Android собирать как-то проще;
— NDK для сборки С кода в статическую библиотеку;
— Android Studio со всеми его потребностями вроде Java и т. д. Студия нужна для удобного логгирования происходящего в приложении.

Пройдемся по интересным моментам:

1. Ситуация с отладкой плагинов в Android довольно печальная, так что рекомендую сразу подумать о записи логов в файл. Можно, конечно, заморочиться и попробовать настроить удаленную отладку, но у меня для этого не было времени и пришлось пойти более простым путем, просматривая логи через Android Studio. Для этого в android/log.h есть функция __android_log_vprint, которая работает аналогично printf. Для удобства обернул в кроссплатформенный вид:

static void DebugLog (const char* fmt, ...)
{
   va_list argList;
   va_start(argList, fmt);
   #if UNITY_ANDROID
      __android_log_vprint(ANDROID_LOG_INFO, "MyPluginDebugLog", fmt, argList);
   #elif
      printf (fmt, argList);
   #endif
   va_end(argList);
}


Советую не обходить стороной asserts. В случае их срабатывания Android Studio позволяет просмотреть полный стек вызовов.

2. На этой платформе особая специфика именования плагинов —  libMyPluginName.so. Например, префикс lib является обязательным (более подробно можно почитать в документации Unity).

3. В Android-приложении все ресурсы хранятся в одном бандле, который является jar или zip файлом. Мы не можем просто так открыть стрим и начать читать данные, как в остальных платформах. Кроме пути к видео, необходим Application.dataPath, который содержит путь к Android apk, только таким образом можем получить и открыть нужный ассет. Отсюда получаем длину файла и его смещение относительно начала бандла:

unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"))
activity = unityPlayer.GetStatic("currentActivity")
assetMng = activity.Call("getAssets")
assetDesc = assetMng.Call("openFd", myVideoPath);

offset = assetFileDescriptor.Call("getStartOffset");
length = assetFileDescriptor.Call("getLength");


Открываем файлстрим по пути Application.dataPath стандартными средствами (fopen или тем, что вам больше нравится), и начинаем читать файл со смещением offset — это и есть наше видео. Длина нужна, чтобы знать, когда закончится видео файл и остановить дальнейшее чтение.

4. Обнаружил баг.

s_DeviceType = s_Graphics->GetRenderer();


s_DeviceType  всегда содержит kUnityGfxRendererNull. Судя по форумам, это ошибка Unity. Обернул Android часть кода в дефайн, где по умолчанию определил:

s_DeviceType = kUnityGfxRendererOpenGLES


При разработке под Android надо быть готовым к постоянному копанию в консоли и регулярной пересборке либ. Если изначально корректно настроить Android.mk и Application.mk, то проблем со сборкой не должно возникнуть.

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


По моим предварительным подсчетам, эта работа должна была занять 2–3 недели, но ушло 2 месяца. Большинство времени пришлось потратить на выяснение описанных выше моментов. Самый нудный и долгий этап — это Android. Процесс пересборки статических библиотек и проекта занимал около 15 минут, а отладка происходила с помощью добавления новых логов. Так что запаситесь кофе и наберитесь терпения. А еще не забываем про частые падения и зависания Unity.

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

Спасибо за внимание.

© Habrahabr.ru