Недавно столкнулся с написанием плагинов под 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, да и то не всегда.

OSX


Наверное, самая простая и беспроблемная платформа. Плагин собирается быстро, дебажить тоже легко. В 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 на разные события. Очень полезный инструмент для работы с графикой. Скриншот:



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

Windows


На этой платформе  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. Когда, казалось, уже все было готово, обнаружилась неожиданная проблема. Иногда и только на некоторых видео картинка выводилась со смещением, как показано на скриншоте:



Судя по виду изображения, ошибка могла присутствовать в алгоритме копирования данных в текстуру, в текстурных координатах или была связана с  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:



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

IOS


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

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

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

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

[DllImport("__Internal")]

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

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

#import <UIKit/UIKit.h>
#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


Сборка под 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<AndroidJavaObject>("currentActivity")
assetMng = activity.Call<AndroidJavaObject>("getAssets")
assetDesc = assetMng.Call<AndroidJavaObject>("openFd", myVideoPath);

offset = assetFileDescriptor.Call<long>("getStartOffset");
length = assetFileDescriptor.Call<long>("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.

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

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

Комментарии (10)


  1. AlbertWesker
    21.02.2016 10:30

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

    В DirectX SDK есть утилита PIX с которой всё удобно дебажится. https://en.wikipedia.org/wiki/PIX_(Microsoft) Но DxSDK давно уже не обновляется, а удобные инструменты дебага перенесены прямо в VisualStudio https://msdn.microsoft.com/ru-ru/library/hh873207(v=vs.140).aspx


    1. RRendeRR
      21.02.2016 12:05

      Спасибо, что напомнили. Для DX9 я перебрал много тулзов, но они были все бесполезны, и я решали не останавливаться на их обзоре.
      Вначале PIX долго отказывался запускаться на Win7 64 bit. После очередной скачанной версии, он все же завелся, но захватить фрейм из Unity проекта так и не получилось...

      Тулзы в VisualStudio работают с DX10 и старше. К сожалению, для нас DX9 API является основным. Учитывая прошлогоднюю статистику, около 40% наших пользователей имеют видеоадаптер с поддержкой DX9 максимум.
      Еще из интересного я пробовал 3D Ripper http://www.deep-shadows.com/hax/3DRipperDX.htm. Он делал полный дамп памяти DX9 проекта и сохранял в человеческом виде на диск. К сожалению, Ripper не обновляется с 2011 года и сейчас почти не работает, но проекты 2010-х годов вскрывает на ура.

      Если эта информация полезна, то могу ее добавить в статью — в раздел разработки под Windows.


  1. AlbertWesker
    21.02.2016 12:28
    +1

    Решил проверить, скачал и установил DXSDK June 2010 на Unity 5.3.2p4 собрал билд под DX9 и он прекрасно запустился под PIX-ом, захват фрейма прошёл успешно. После чего сделал билд для DX11 и так же успешно отдебажил под VisualStudio 2015. Правда я сейчас на Windows10 но и на 7 у меня всё работало отлично.


    1. AlbertWesker
      21.02.2016 12:38

      Пруф в виде скриншота PIX

      Скриншот


    1. RRendeRR
      21.02.2016 12:53

      Очень интересно! Завтра проверю PIX с июньского SDK и сообщу о результатах.
      У меня тулзы VisualStudio 2015 тоже работали на DirectX 10 и выше. Поскольку разработка велась под 9-ку, они были не актуалены.


    1. RRendeRR
      23.02.2016 16:36

      Вы были правы. Июньский PIX работает с DX9 в Unity проектах.
      Изначально, при попытке захватить фрейм, приложение просто падало. Попробовал другую машину с свежей виндой, сразу поставил июньский DX SDK, и свежий Unity – все заработало. Видать винда, на которой велась разработка слишком загажена, она уже пару лет не менялась...


  1. house2008
    22.02.2016 21:40

    Если будете пользоваться Unity функцией для каллбэков из ios натива в своей статичной либе, то есть

    void UnitySendMessage(const char* obj, const char* method, const char* msg);

    то чтобы либа скомпилировалась, нужно подключить Unity библиотеку (libiPhone.a) или же можно в Xcode проекте добавить .pch файл c ее объявлением

    #ifdef __cplusplus
    extern "C" {
    #endif
    
    void UnitySendMessage(const char* obj, const char* method, const char* msg);
    
    #ifdef __cplusplus
    }
    #endif

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


  1. Dipp
    22.02.2016 23:11

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


    1. RRendeRR
      23.02.2016 16:50

      Расскажите поподробней, что за проблемы возникли в имеющихся решениях?

      Не буду устраивать антирекламу другим плагинам, напишу только нюансы с которыми столкнулся.
      Года пол назад купили самый рекомендуемый плагин для проигрывания видео, в нем не понравилось:

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

      Не думаете о релизе своего плагина в ассет-сторе?

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


      1. Dipp
        24.02.2016 20:07

        Понятно, спасибо большое за информацию.