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

в 20:36, , рубрики: android, DLC, game development, Gamedev, iOS, mobile development, Mono, unity, unity3d, метки: , , , , , , , , ,

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

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

  1. Сначала нужно скачать DLC с сервера. Делаем это согласно коду из мануала Unity. Далее пишем загруженные данные в файл на диск для дальнейшего использования.
    // Start a download of the given URL using assetBandle 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(pathToAssetBandle);
    
      //синхронная загрузка текстуры из 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.
Вариант подходит для относительно небольших AssetBaundle-ов, т.к. потребляет очень много оперативной памяти.

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

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

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

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

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

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

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

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

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

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

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

Спасибо за внимание. Буду рад ответить на ваши вопросы.

Автор: mmortall

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js