Недавно, для одной игры на 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 хорошо описано в документации, поэтому не будем на этом останавливаться.
Далее, после запуска игры делаем следующие шаги:
- Сначала нужно скачать 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 не поддерживает буферизированною загрузку и хранит всю загруженную информацию в памяти. Мы поговорим об этой проблеме чуть позже. Пока запомним этот момент и пойдем дальше.
- Загрузка ресурсов из 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.
Далее алгоритм действий был следующим:
- При создании бандла указывалась опция BuildOptions.UncompressedAssetBundle, чтобы получить несжатый бандл.
- Затем бандл архивировался и шифровался архиватором и заливался на сервер.
- Во время работы приложения создавался отдельный поток, который в бэкграунде выкачивал бандлы, распаковывал их и складывал в специальную папку.
Тут у нас возникла еще одна проблема. Т.к. мы теперь используем сжатый архиватором бандл, мы уже не можем выкачивать его функцией LoadFromCacheOrDownload. А значит, теперь мы должны определить нашу собственную систему контроля версий для DLC.
Для системы контроля версий DLC было выбрано следующее решение. На сервере в папке, где лежали фалы DLC завели текстовой файл dlcversion. Он содержал список DLC в папке и md5 хеши для них. Эти хеши считались на этапе аплода DLC на сервер. На клиенте имелся такой же точно файл, и при старте приложения клиент сравнивал свой файл с файлом на сервере. Если какой-то DLC файл имел отличные хеши или хеша вовсе не было, считалось, что файл на клиенте устарел и клиент подтягивал с сервера новый файл DLC.
После того, как новый файл DLC был скачан и распакован, его хеш еще раз сверялся с серверным, и только после этого устаревший файл заменялся на новый и в файле dlcversion клиента делалась соответствующая запись.
Описанная система была успешно имплементирована и отлично работает. Единственный минус, который мы имели, это небольшие просадки по fps (лаги) при закачке и распаковке DLC в бэкграунде. А также немного возросли пиковые значения потребления памяти приложения.
Спасибо за внимание. Буду рад ответить на ваши вопросы.
Автор: mmortall