Реализация системы динамически загружаемого контента (DLC) для мобильной игры в Unity 3D

Недавно, для одной игры на Unity 3D, которую мы разрабатывали, возникла необходимость добавить DLC систему. Хотя это оказалось далеко не так просто, как казалось в начале, мы успешно справились с возникшими проблемами и игра ушла в gold. В этой статье я хочу изложить наш вариант реализации DLC, рассказать о возникших проблемах и как мы их решили.

Постановка задачи


В игре есть магазин, где игрок покупает вещи за игровую или реальную валюту. В магазине – более 200 вещей. Когда игрок заходит в игру, ему доступно 20 вещей в магазине. Если есть интернет, игра без ведома юзера опрашивает сервер на предмет наличия DLC и, если таковое имеется, скачивает в бэкграунде. Когда игрок повторно зайдет в магазин, он увидит все новые вещи из DLC.
Еще есть набор локаций. Каждая локация имеет набор текстур и .asset файлов. Новые локации также должны добавляться через DLC.
Загрузка ресурсов из DLC должна быть синхронной.
Платформа: iOS (iPhone 3GS и выше.) и Android (Samsung Galaxy S и выше).

Содержимое DLC и работа с ним в игре


В игре вещи полностью определяются файлом itemdata.txt, в котором содержится информация о вещах и их текстурах. Значит, в каждом DLC будет находиться файл itemdata.txt с набором тех вещей, которые есть в DLC + тестуры для этих вещей. А когда магазин запросит базу данных вещей, мы склеим все текстовые файлы со всех DLC и дадим ему этот файл.
Аналогично для локаций есть файл locationdata.txt со списком и характеристиками локаций + текстуры и asset файлы для них.
Соответствующий код на C# для загрузки ресурсов в игровой логике будет выглядеть так:

public String GetItemDataBase() {
  if(DLCManager.isDLCLoaded() == true) {
    //склеить все файлы itemdata.txt во всех загруженных DLC и вернуть как один string
    String itemListStr = DLCManager.GetTextFileFromAllDLCs(“itemdata”); 
    return itemListStr;
  }
  else {
    //загружаем файл по умолчанию
    TextAsset  itemTextFile = Resources.Load(“itemdata”) as TextAsset;
    return itemTextFile.text;
  }
  
  return String.Empty;
}


Аналогично при запросе текстуры, мы проверяем её наличие в DLC. Если она там есть, загружаем, иначе загружаем из игровых ресурсов. Если и там нет, то загружаем что то дефолтное.

public Texture GetTexture(string txname) {
  Texture tx = null;
  if(DLCManager.isDLCLoaded() == true) {
    tx = DLCManager.GetTextureFromDLC(txname);
  }
  if(tx == null) {
    tx = Resources.Load(txname) as Texture;
  }
  if(tx == null) {
    Assert(tx, “Texture not find: ” + txname);
    tx = Resources.Load(kDefaultItemTexturePath) as Texture;
  }
  return tx;
}


Аналогично для файлов .asset будет функция GetAsset(string assetName). Её реализация будет аналогичной, поэтому пропустим её.

Файл DLC


Мы определились, что у нас должно быть в DLC. Осталось определиться, в виде чего это все хранить.

Первый вариант – хранить DLC в виде зип архива. В каждом архиве – текстовой файл + N текстур. Текстуры должны быть в формате PVRTC для экономии видео памяти. Но тут мы имеем первую проблему – Unity поддерживает загрузку текстур из файловой системы только в формате PNG или JPG [link]. Затем текстуру можно записать в PVRTC текстуру [link]. Это медленный процесс, т.к. требует переконвертации в PVR в риалтайме. К тому же т.к. в DLC планируется хранить файлы типа .asset, а возможно и игровые уровни (.scene), такой метод и вовсе непригоден.

Второй вариант – использовать AssetBundle. Это решение идеально подходит для DLC в играх.
Судя по документации, он обладает массой плюсов:
  • Может хранить любые ресурсы Unity, включая сжатые в нужный формат текстуры (то что нам нужно).
  • Это архив с хорошим сжатием.
  • Просто и удобно использовать.
  • Поддерживает параметр version и хеш сумму (при загрузке функцией LoadFromCacheOrDownload), что удобно для контроля версий DLC


Из минусов только то, что AssetBundle требует Pro версию Unity и не поддерживает шифрование. Решили остановиться на этом решении, т.к. оно очевидно более привлекательно и позволяет решить все наши задачи.

Имплементация (Вариант 1)


Для начала была сделана тестовая версия DLC системы с самым элементарным функционалом.
Сначала все 200 с лишним текстур магазинных итемов и файлы локаций были упакованы в один AssetBundle и залиты на сервер. Файл получился порядка 200 мб. Упаковка в AssetBundle выполнялась скриптом в эдиторе. Как сделать упаковку ресурсов в AssetBundle хорошо описано в документации. Вы также можете использовать мой скрипт для создания AssetBundle.

Далее, после запуска игры делаем следующие шаги:

  1. Сначала нужно скачать DLC с сервера. Делаем это согласно коду из мануала Unity. Далее пишем загруженные данные в файл на диск для дальнейшего использования.

    // Start a download of the given URL using assetBundle version and CRC-32 Checksum
    WWW www = WWW.LoadFromCacheOrDownload (urlToAssetBundle, version, crc32Checksum);
    
    // Wait for download to complete
    yield return www;
    
    // Get the byte data
    byte[] byteData = www.bytes;
    
    // Тут можно вставить свой метод дешифровки бандла, если необходимо
    byteData = MyDescriptionMethod(byteData);
    
    //сохраняем byteData в файл с расширением .unity3d
    ...
    
    // Frees the memory from the web stream
    www.Dispose();
    
    //DLC успешно загружено и его можно использовать в игре
    DLCManager.SetDLCLoaded(true);
    


    На этом коде мы c большой вероятностью получим креши по памяти на low девайсах вроде iPhone 3GS, т.к. класс WWW не поддерживает буферизированною загрузку и хранит всю загруженную информацию в памяти. Мы поговорим об этой проблеме чуть позже. Пока запомним этот момент и пойдем дальше.

  2. Загрузка ресурсов из DLC.
    Теперь нам нужно определить функции GetTextureFromDLC(), GetAssetFromDLC() и GetTextFileFromAllDLCs(). Определение последних пока опустим, т.к. оно почти ничем не будет отличаться от первой кроме типа загружаемого ресурса.

    Основная задача функции GetTextureFromDLC – синхронная загрузка текстуры по имени из DLC.
    Попробуем определить её следующим образом.

    public Texture GetTextureFromDLC(String textureName) {
    
      //загружаем DLC с диска. Можем использовать только синхронный метод.
      AssetBundle asset = AssetBundle.CreateFromFile(pathToAssetBundle);
    
      //синхронная загрузка текстуры из DLC
      Texture  texture = asset.Load(textureName) as Texture;
    
      //выгрузка бандла из памяти без удаления объекта texture
      asset.Unload(false);
    
      return texture;
    }
    



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

Функция AssetBundle.CreateFromFile согласно документации синхронно загружает ассет с диска. Но есть один нюанс – «Only uncompressed asset bundles are supported by this function.» Таким образом, синхронно загрузить возможно только несжатый AssetBundle. Что существенно увеличит трафик и время загрузки DLC с сервера. К тому же Unity не поддерживает конвертацию AssetBundle из сжатого в несжатый, поэтому не получится скачать сжатый бандл, а потом распаковать его на клиенте.

Читатель может задаться вопросом, почему бы не загрузить AssetBundle асинхронно, например, функцией LoadFromCacheOrDownload, а затем просто брать из него нужные ресурсы синхронно. Ведь логично, что AssetBundle при загрузке из файловой системы должен подгрузить только заголовок файла, а потому в памяти должен заниматься немного.

Однако это оказалось не так. Загруженный AssetBundle хранится в памяти полностью со всем своим содержимым в распакованном виде. Таким образом, чтобы загрузить одну текстуру из 200, Unity загрузит все 200 текстур в память, возьмет одну, а потом освободит память для остальных 199 текстур. Мы это выяснили экспериментально по замерам памяти на девайсе.
Очевидно, что для мобильных устройств это неприемлемо.

Резюме


Приведенный вариант — единственный найденный нами способ реализации синхронной загрузки DLC и ресурсов из него.
Требуется несжатый AsssetBundle, что приводит к большие потерям времени и трафика при загрузке DLC.
Вариант подходит для относительно небольших AssetBundle-ов, т.к. потребляет очень много оперативной памяти.

Работа над ошибками (Вариант 2)


Попробуем учесть все предыдущие проблемы и найти решения для них.

Проблема с загрузкой больших assetBundle-ов можно решить двумя способами.
Первый – использовать класс WebClient. Однако с ним у нас возникли проблемы на iOS. WebClient ничего не мог скачать, однако на десктопе работал отлично.
Второй вариант – использовать нативные функции ОС. Например, NSURLConnection для iOS и URLConnection для Android соответственно, которые поддерживаю буферизированную загрузку прямо в файл на диске.
Но это не такая уж и большая проблема, т.к. нам в любом случае надо уменьшать размер AssetBundle для синхронной загрузки. Поэтому пока мы оставили текущий способ загрузки бандлов с сервера.

Намного более серьезная проблема – синхронная загрузка AssetBundle. Т.к. он должен быть не только несжатым, но и занимать мало места в памяти, мы так или иначе должны разбивать наш один большой файл DLC на много маленьких файлов. Однако, если мы разобьем на слишком маленькие файлы, их будет много и это сильно увеличит время загрузки, т.к. придется для каждого файла устанавливать соединение заново. Значит, нам таки придется хранить их сжатыми для лучшей экономии времени загрузки и трафика.

Для решения этой проблемы было решено использовать свой собственный архиватор. Была выбрана открытая библиотека архиватора для C#, которую без особых усилий получилось завести под Mono в Unity.

Далее алгоритм действий был следующим:

  1. При создании бандла указывалась опция BuildOptions.UncompressedAssetBundle, чтобы получить несжатый бандл.
  2. Затем бандл архивировался и шифровался архиватором и заливался на сервер.
  3. Во время работы приложения создавался отдельный поток, который в бэкграунде выкачивал бандлы, распаковывал их и складывал в специальную папку.


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

Для системы контроля версий DLC было выбрано следующее решение. На сервере в папке, где лежали фалы DLC завели текстовой файл dlcversion. Он содержал список DLC в папке и md5 хеши для них. Эти хеши считались на этапе аплода DLC на сервер. На клиенте имелся такой же точно файл, и при старте приложения клиент сравнивал свой файл с файлом на сервере. Если какой-то DLC файл имел отличные хеши или хеша вовсе не было, считалось, что файл на клиенте устарел и клиент подтягивал с сервера новый файл DLC.

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

Описанная система была успешно имплементирована и отлично работает. Единственный минус, который мы имели, это небольшие просадки по fps (лаги) при закачке и распаковке DLC в бэкграунде. А также немного возросли пиковые значения потребления памяти приложения.

Спасибо за внимание. Буду рад ответить на ваши вопросы.
  • +14
  • 29,8k
  • 9
Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 9
  • 0
    Почему просто не закачивать в фоне ресурсы и не складывать их в папку пользовательских данных (sdcard, etc)? А потом грузить только то, что нужно. Архивация для уменьшения времени загрузки тоже звучит странно — какая разница, если данные едут в фоне и будут доступны только при следующем запуске приложений и только в случае успешной докачки? Единственный минус — придется придумать свои форматы для всего, чтобы можно было быстро загружать из локальных файлов + сжатие текстур на лету не всегда работает в принципе (загрузка и перекладывание через Get/SetPixels в текстуру со сжатием не всегда помогают — DXT5 на десктопе отрабатывало как надо, на android tegra2 — не работало в принципе, всегда возвращая ARGB32).
    • 0
      Почему просто не закачивать в фоне ресурсы и не складывать их в папку пользовательских данных

      Как я уже писал в статье, если вам не нужно использовать нативные файлы Unity, вы можете не использовать AssetBundle. В нашей ситуации мы были вынуждены его использовать.

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

      Во-первых, данные будут доступны как только закачается соответствующее DLC. О перезапуске приложения я ничего не писал. Данные будут обновляться по мере загрузки DLC. Скачали 20 вещей, они сразу появились в игре когда юзер повторно зашел в магазин. Об это я написал в постановке задачи.
      Во-вторых, есть большая разница закачивать 200 мб или 60 мб. Особенно на 3G интернете.
      • 0
        Как я уже писал в статье, если вам не нужно использовать нативные файлы Unity, вы можете не использовать AssetBundle. В нашей ситуации мы были вынуждены его использовать.

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

        Т.е. реализовать кастомную сериализацию для «нейтивных» файлов юнити с последующей десериализацией в рантайме любым удобным способом (хоть в параллельном потоке с синком в главный только в момент готовности данных).

        О перезапуске приложения я ничего не писал.

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

        Во-вторых, есть большая разница закачивать 200 мб или 60 мб. Особенно на 3G интернете.

        Сейчас игрушки под ретину начинают весить до 1.5-2Гб + апдейты к ним. Несчастные 200Мб вообще ни о чем. Но более правильной политикой было бы предлагать юзверу самому решить — качать DLC на текущем коннекте или нет + опцию в настройках.
        • 0
          Т.е. реализовать кастомную сериализацию для «нейтивных» файлов юнити с последующей десериализацией в рантайме любым удобным способом (хоть в параллельном потоке с синком в главный только в момент готовности данных).


          Интересная идея, мы думали об этом. Это может сработать для файлов типа asset, правда не сработает для scene файлов.
          На мой взгляд это выглядит как еще один хак. Нет точных гарантии, что asset будет корректно сериализроваться и десериализоваться. К тому же даже если этот механизм будет работать идеально, мы имеет дополнительные затраты на конвертацию ассетов в «наш формат», и затем обратную конвертацию в ассет. А также это потенциально новые баги в багобазе.
          В нашем варианте AssetBundle берет на себя часть задач и гарантирует целостность данных внутри. Все таки решение с бандлом мне видится более надежным.

          Сейчас игрушки под ретину начинают весить до 1.5-2Гб + апдейты к ним. Несчастные 200Мб вообще ни о чем.

          Ну далеко не все игры. Наша игрушка весила всего 40 мб. По сути, как демо версия. Дальше она выкачивала порядка 300 мб DLC. Причем под разные девайсы были разные ресурсы. И под ретину DLC было в разы больше.

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

          Согласен с вами полностью. Но игра предназначалась домохозяйкам и опций должно было быть минимум.
          • 0
            Нет точных гарантии, что asset будет корректно сериализровться и десериализроваться. К тому же даже если этот механизм будет работать идеально, мы имеет дополнительные затраты на конвертацию ассетов в «наш формат», и затем обратную конвертацию в ассет. А также это потенциально новые баги в багобазе.
            В нашем варианте AssetBundle берет на себя часть задач и гарантирует целостность данных внутри. Все таки решение с бандлом мне видится более надежным.

            Я имел ввиду не бинарную сериализацию ассета в лоб, а именно полностью свой (оптимизированный) вариант сериализации для всего. Например, шейдеры в ассетах хранятся в скомпилированном виде для всех поддерживаемых платформ. Зачем, блин? Ну и так для всего — можно сделать различные оптимизации (те же нормали хранить в 2-х short-ах и восстанавливать 3-ий компонент, хранить UV так же в 2 short-ах, если они нормализованы, etc). Ну и, как говорилось, это все можно грузить как-угодно в фоне, синкаясь с основным потоком только в момент инстанцирования. По поводу подготовки данных — редактор юнити прекрасно расширяется и позволяет тесно интегрировать свой функционал бесшовно в основной редактор, к которому дизайнеры уже привыкли. В дальнейшем просто рисуются комиксы в картинках (инструкции) по использованию новых пунктов и все работает как надо.
            А также это потенциально новые баги в багобазе.

            Зато в дальнейшем работающие выжимки можно использовать сразу в следующих проектах, а так же срубить денег в юнисторе :)
            • 0
              Ну да, можно и так конечно.
              Получится вроде своего DLC с пасьянсом и профурсетками )
              Беглый взгляд по ассет стору ничего подобного не выдал, так что место пока вакантно.

              Но я в ближайшее время не планирую заниматься DLC. Есть другие более интересные задачи.
    • +3
      Для того чтобы юнити стала удобной и гибкой, необходимо заставить самих разработчиков юньки писать на своем же движке. А то каждая задача это борьба с движком!
      • +2
        Native плохо пишется на managed :)
      • 0
        //сохраняем byteData в файл с расширением .unity3d
        не совсем понял для чего нужен этот шаг, ведь LoadFromCacheOrDownload сама сохраняет версию на диск и следит за ее актуальностью?

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