Сьогодні я хочу поділитися своєю подорожжю створення архівних файлів для ігрових активів. Вірогідно, ви вже стикалися з цією концепцією, адже багато ігор використовують власні формати архівів для зберігання та завантаження активів. У цій статті я спробую детально описати, як я реалізував цю функціональність в індивідуальному рушії.
Приклад використання архівних файлів активів (.pak) у грі Baldur's Gate 3 від Larian Studios.
Коротке введення
Я — другокурсник в Університеті прикладних наук Бреди, де навчаюся на курсі «Творчі медіа та технології ігор», обравши програмування як основний напрямок. Протягом останніх 8 тижнів я працював над створенням функціоналу «Пакетованих активів», що може бути легко вбудовано в будь-який індивідуальний рушій, і це — головна тема цієї статті. Я обрав саме цю тему як проєкт самонавчання, адже часто задавав собі питання, яке також зазначене на самому початку цієї статті, знаючи, що це — функціонал, який зустрічається в більшості ігор, але майже не описується в документації онлайн.
Увага: деякі терміни, такі як "компресія", "контрольні суми" та "шифрування", будуть розглянуті пізніше в статті. Тим часом просто вважайте їх важливими функціями, якими можуть володіти архіви активів!
Що таке архівний файл активів?
Архівний файл активів — це зазвичай власний формат файлу, який дозволяє зберігати кілька файлів в одному великому файлі. В ігровій розробці цей формат файлів зазвичай включає такі функції: дерева файлів (створення власної файлової системи), компресія, шифрування, контрольні суми — це робиться для зменшення розміру файлів на диску, зниження ймовірності модифікації ігрових активів та перевірки на корупцію даних. Під час випуску гри дуже важливо, щоб активи завантажувалися без затримок за запитом, і це саме те, де архіви активів можуть стати у пригоді, оскільки читання кількох файлів, що щільно упаковані в один файл, може бути значно швидшим за читання кількох файлів окремо (менше операцій з файловою системою, і переважно через те, що операційна система зберігає метадані, такі як дозволи на файли або мітки часу, які для більшості випадків ігрової розробки не мають значення).
Оскільки всі наші дані зберігаються у вигляді сирого двійкового формату, ось як це буде виглядати при перевірці з використанням гекс-редактора (в даному випадку HxD):
Архів активів з мого проєкту, перевірений за допомогою гекс-редактора, що містить імена файлів, контрольні суми та сирі двійкові дані GLTF і PNG тощо.
Що потрібно зберігати в архівах файлів?
В архіві файлів можна зберігати багато корисних даних, але значно краще зберігати лише ті дані, які ви знаєте, що вам можуть знадобитись у майбутньому. Ось кілька прикладів: прапори та відповідні метадані для всього архіву (наприклад, кількість файлів в архіві, прапор для перевірки, чи містить цей архів зашифровані або стиснуті дані, прапор для перевірки, чи використовує архів контрольні суми хешів тощо), метадані активів (наприклад, розмір файлу, стислий розмір, зашифрований розмір, тип файлу тощо), імена файлів активів або UUID (Універсально унікальні ідентифікатори). Додатково, якщо у архіві використовуються контрольні суми, корисно зберігати їх як окремий блок даних у межах архіву, оскільки вам доведеться їх перевірити, коли ви намагатиметесь розпакувати архів.
Але найголовніше, вам необхідно зберігати сирі двійкові дані ваших фізичних файлів.
Ось приклад на C++, який показує, як це відбувається:
void ReadBinaryFile(const std::string& filename, std::vector& buffer, size_t& file_size)
{
std::ifstream file( filename, std::ios::binary | std::ios::ate );
std::streamsize size = file.tellg(); // отримуємо розмір файлу, переміщаючись в кінець файлу
file_size = size;
file.seekg( 0, std::ios::beg );
buffer.resize( file_size );
file.read( reinterpret_cast( buffer.data() ), size ); // читаємо сирі двійкові дані файлу в буфер
file.close();
}
std::vector asset_in_raw_binary;
size_t file_size;
ReadBinaryFile("my_asset.png", asset_in_raw_binary, file_size);
std::ofstream my_file("asset1.bin", std::ios::binary);
my_file.write(reinterpret_cast(asset_in_raw_binary.data()), file_size);
my_file.close();
Створення власних структур даних для зберігання важливих частин даних
Згідно з інформацією з попереднього розділу (Що потрібно зберігати в архівах файлів?), я створив кілька структур на C++, що зберігають відповідні частини даних, які зазвичай зберігаються в архіві файлів. Нижче наведені ці структури.
struct MyHeader
{
uint32_t number_of_files;
// прапори
uint8_t checksum;
uint8_t encryption;
};
struct MyFileMetadata
{
uint32_t file_size;
uint32_t compressed_size;
uint32_t encrypted_size;
uint32_t file_number; // номер файлу в архіві
uint8_t file_type;
uint8_t compressed; // цей прапор вимкнено, якщо file_type — це зображення (PNG, JPG компресія)
};
Давайте розберемо деякі з цих моментів, а також обговоримо вибір типів даних:
“MyHeader::numberoffiles” — ця змінна буде зберігати загальну кількість файлів в архіві. Для цієї змінної використовується тип даних “uint32_t”, оскільки максимальне значення беззнакового цілого числа становить 4294967295. Це достатньо для зберігання файлів в одному архіві (цікавинка: на Windows папки також мають таке значення як максимальну ємність зберігання файлів, тому це «імітує» можливості ОС у вашому коді).
“MyHeader::checksum” — ця змінна зберігає тип алгоритму хешування контрольних сум, який буде використовуватися в архіві. Якщо це значення дорівнює 0, контрольні суми не активовані. Наприклад, у моєму проєкті я реалізував підтримку таких алгоритмів хешування: CRC32, MD5 та SHA-256 — тому для вибору типу алгоритму хешування значення булевого типу (0 та 1) не буде достатньо, тому я вибрав найменший тип даних “uint8_t” (0 до 255).
“MyFileMetadata::file_size” — ця змінна зберігає загальний розмір поточного файлу в “uint32_t” змінній, чого цілком достатньо, оскільки ви навряд чи знайдете активи, які будуть більші за 4.2 гігабайти (максимальне значення беззнакового цілого числа, переведене в гігабайти). Ця частина даних також стане в нагоді, коли в архіві буде підтримка компресії/дефрагментації або шифрування/дешифрування, оскільки для всіх цих операцій потрібно використовувати початковий розмір файлу.
“MyFileMetadata::file_type” — ця змінна зберігає тип файлу в змінній типу “uint8_t”. Оскільки ваш архів не буде підтримувати всі можливі типи файлів, вам знадобиться лише кілька типів, щоб помістити їх у одну змінну байт, від 0 до 255 (звісно, вам слід попередньо визначити, який тип відповідає кожному індексу у вашому коді).
Ви можете використовувати цю змінну для виконання конкретних операцій над певними типами файлів.
Ось швидкий приклад того, як зберігати такі дані у файлі:
MyHeader header{ 1, 0, 0 };
std::vector asset_in_raw_binary;
size_t file_size;
ReadBinaryFile("my_asset.png", asset_in_raw_binary, file_size);
MyFileMetadata metadata{ file_size, 0, 0, 0 };
std::ofstream my_archive("archive.bin", std::ios::binary);
my_archive.write(reinterpret_cast(&header), sizeof(MyHeader));
my_archive.write(reinterpret_cast(&metadata), sizeof(MyFileMetadata));
my_archive.write(reinterpret_cast(asset_in_raw_binary.data()), file_size);
my_archive.close();
Парсинг і відлагодження даних (з файлу, вміст якого вам відомий)
Ми змогли зберегти необхідні дані в нашому власному форматі файлів. Це чудово, але нам також потрібно рухатися вперед — реалізувавши парсинг цих даних, інакше все це було б зроблено даремно.
Оскільки ми визначили власні типи даних, використовуючи структури і подібне (наприклад, “MyHeader”, “MyFileMetadata” тощо), цей крок буде дуже простим. Нам потрібно лише оголосити змінні з вказаними типами та безпосередньо зчитати дані з файлу в ці змінні. Ось приклад того, як це легко робиться (також на основі попереднього прикладу запису даних архіву та активів у файл, але цього разу ми читаємо з файлу):
MyHeader header;
MyFileMetadata metadata;
std::vector asset_in_raw_binary;
std::ifstream my_archive("archive.bin", std::ios::binary | std::ios::ate);
my_archive.seekg(0, std::ios::beg); // читаємо з початку файлу
my_archive.read(reinterpret_cast(&header), sizeof(MyHeader));
my_archive.read(reinterpret_cast(&metadata), sizeof(MyFileMetadata));
my_archive.read(reinterpret_cast(asset_in_raw_binary.data()), metadata.file_size));
my_archive.close();
Тепер, коли ми знаємо, як парсити такі дані з файлу, з ними можна робити багато різних речей (наприклад, завантажувати зображення, стискати дані, шифрувати дані тощо).
Важливим етапом роботи з такими даними є додавання повідомлень для відлагодження про те, що зараз читається чи записується з архівного файлу. Крім того, можна додавати прямі попередні перегляди метаданих і даних активів у інтерфейсі вашого рушія (приклад нижче).
Знімок екрана з мого проєкту, що містить розпарсені дані з архіву активів та журнал відлагодження процесу парсингу (Розпаковані активи коректно відображаються в ImGui з правого боку зображення)
Увага: З цього моменту деталі стають трохи технічнішими! Якщо хочете, можете пропустити до розділу “Висновок”.
Стиснення та розпакування даних за допомогою бібліотеки ZSTD
Зараз, коли комп'ютери та технології розвиваються, ми можемо зберігати набагато більше даних і завантажувати ці великі обсяги даних значно швидше, ніж раніше. Але в результаті деякі наші файли стають надзвичайно великими, і ми не хочемо, щоб наша гра важила 300+ гігабайтів. Тому існує стиснення даних.
До і після упаковки та стиснення понад 3000 .glb моделей на максимальному рівні стиснення за допомогою ZSTD
Стиснення даних — це процес (або, можна сказати, мистецтво) кодування існуючих даних файлу з використанням меншої кількості бітів, ніж у оригінальному представленні. Існує два типи алгоритмів стиснення: без втрат (без втрат даних) та з втратами (з втратою даних). Безвтратне стиснення в основному зосереджується на усуненні зайвих даних із блоба, тоді як стиснення з втратами зосереджується на усуненні неважливих або непотрібних даних.
Ми зосередимось на стисненні без втрат, оскільки саме цей метод я використав у своєму проєкті.
Для досягнення стиснення моїх архівів активів, я використав дві різні бібліотеки стиснення (і розробник може вибрати, яку бібліотеку стиснення використовувати для архіву):
- ZSTD (повільніше, але більш ефективне за коефіцієнтом стиснення)
- LZ4 (найшвидше, менш ефективне за коефіцієнтом стиснення)
Щоб не повторюватися занадто багато разів, я зосереджусь на способі, яким я реалізував (де)стиснення за допомогою бібліотеки ZSTD, але реалізація стиснення LZ4 дуже схожа на реалізацію ZSTD.
Для стиснення даних потрібно наступне: незтиснений блок даних, розмір незтиснених даних, буфер для збереження стиснених даних і змінна для збереження розміру стиснених даних. Крім того, якщо ви не хочете використовувати максимальний рівень стиснення, ви можете змінити його. Бібліотека ZSTD пропонує рівні стиснення від -7 (найшвидший, найменш ефективний) до +22 (найповільніший, найефективніший).
Розпакування даних — це фактично стиснення, виконане в зворотному порядку, тому нам потрібно мати все, що є протилежним до попередніх вимог.
Нижче ви можете знайти два прості приклади коду того, як стиснути і розпакувати дані:
void zstd::CompressFile(u8* uncompressed_data,
const size_t& uncompressed_size,
u8* compressed_data,
size_t& compressed_size,
const int& compression_level)
{
const size_t max_compressed_size = ZSTD_compressBound(uncompressed_size);
compressed_size = ZSTD_compress(compressed_data,
max_compressed_size,
uncompressed_data,
uncompressed_size,
compression_level);
if (ZSTD_isError(compressed_size))
{
Log::Error("Помилка стиснення: {}", ZSTD_getErrorName(compressed_size));
return;
}
}
void zstd::UncompressFile(u8* compressed_data,
u8* uncompressed_data,
const size_t& original_size,
size_t& compressed_size)
{
std::size_t result = ZSTD_decompress(uncompressed_data,
original_size,
compressed_data,
compressed_size);
if (ZSTD_isError(result))
{
Log::Error("Помилка розпакування: {}", ZSTD_getErrorName(result));
return;
}
}
Контрольні суми та чому вони є найважливішою функцією в такому пайплайні
Зазвичай файлові системи мають реалізацію, де, якщо ви намагаєтеся використовувати або отримати файл з пошкодженими даними, система викидає помилку і не дозволяє його використовувати. Оскільки ми фактично керуємо своєю власною файловою системою, створюючи свій архів файлів, ми також повинні вирішити цю проблему. Тут на допомогу приходять алгоритми хешування контрольних сум.
Контрольні суми можна використовувати для перевірки цілісності даних (але вони не гарантують автентичність даних), що є саме тим, що нам потрібно, щоб перевірити, чи не пошкоджені дані, які ми зберігаємо в архівах.
Алгоритми хешування, які використовуються для контрольних сум:
- CRC32 (32 біти) — найшвидший, з більшим діапазоном для накладання; виглядає як: fb1e2671
- MD5 (128 біт) — найбільш використовуваний в іграх; виглядає як:
248cfce10656b0aa30bf1c8bb8c17992 - SHA-256 (256 біт) — найповільніший, з найменшим діапазоном для накладання; виглядає як: 455e1ebb2b2ad658bc0c380233d3ba1afe90393a32b5e7c8b47d0b21c3d119f3
Чим більше біт, тим менше є діапазону для накладання після обчислення контрольних сум для кількох файлів.
Через обмеження часу я був змушений використати бібліотеку хешування, яку я знайшов онлайн, щоб виконати ці хеш-обчислення в моєму проєкті: https://github.com/stbrumme/hash-library
Ось приклад того, як просто використовувати цю бібліотеку:
// CRC32
u32 checksum_hash{};
checksum_hash = crc32_fast(asset_buffer.data(), size);
// MD5
std::string checksum_hash{};
MD5 md5{};
checksum_hash = md5(asset_buffer.data(), asset_buffer.size());
// SHA-256
std::string checksum_hash{};
SHA256 sha256{};
checksum_hash = sha256(asset_buffer.data(), asset_buffer.size());
Інший цікавий випадок використання для хешів контрольних сум — це система посилань на активи, так що, якщо, наприклад, дві різні моделі використовують один і той самий текстурний файл, ви вже матимете його в пам'яті разом з його контрольною сумою — таким чином, після обчислення контрольної суми при завантаженні моделі, ви зможете вказати, що цей файл вже існує в пам'яті, і буде використано первісний екземпляр текстури, яка вже завантажена в пам'ять. (Дякую, Bojan!)
Шифрування
Шифрування даних використовується в архівах файлів для того, щоб дані не могли бути змінені зовнішніми джерелами (або, принаймні, щоб простий користувач не зміг цього зробити). Алгоритми шифрування можна знайти в бібліотеках, таких як OpenSSL, але для простоти і знову через обмеження часу, я використав бібліотеку тільки з заголовковими файлами, яка реалізує кілька алгоритмів шифрування з Стандарту розширеного шифрування (**https://github.com/kkAyataka/plusaes). Оскільки я знайшов приклад шифрування в архівах активів онлайн (знайдено тут), я використав найпростіший алгоритм шифрування: Електронна книжка (ECB).
Як видно з прикладу вище, користувачі з’ясували, що Rockstar Games зашифрувало дані активів, використовуючи алгоритм електронної книжки, де дані шифруються (і розшифровуються) у шматках по 16 байтів. Шифрування даних у менших шматках може покращити продуктивність.
Я дотримувався цього прикладу та імплементував його у своєму коді:
// Засновано на шифруванні даних, знайдених у архівах активів GTA IV:
// https://gtamods.com/wiki/Cryptography#Encryption_Algorithms
void AssetPack::EncryptData(std::vector& raw_data,
size_t& encrypted_size)
{
std::vector encrypted_data{};
static constexpr auto block_size = 16;
const auto block_count = static_cast(raw_data.size() / block_size);
const auto remaining_bytes = static_cast(raw_data.size() % block_size);
encrypted_size = static_cast(block_count * block_size);
encrypted_data.resize(encrypted_size);
for (int i = 0; i < block_count; i++)
{
const u8* block_of_data = raw_data.data() + i * block_size;
u8* encrypted_block = encrypted_data.data() + i * block_size;
plusaes::encrypt_ecb(block_of_data,
block_size,
&key[0],
static_cast(key.size()),
encrypted_block,
block_size,
false);
}
// Цей if блок виконується, якщо raw_data.size() не ділиться на 16
// Залишкові байти не шифруються (так само як і в прикладі з архівом GTA IV)
if (remaining_bytes > 0)
{
encrypted_data.insert(encrypted_data.end(), raw_data.end() - remaining_bytes, raw_data.end());
}
std::swap(raw_data, encrypted_data);
}
Зачекайте! Ми пропустили крок, і наше шифрування не працює так, як передбачено. Для шифрування (або розшифрування) даних використовується “ключ”, оскільки вся лінія шифрування та розшифрування залежить від цього аспекту.
std::vector key = plusaes::key_from_string(&"BredaUniversity1"); // 16 символів
Ви можете зробити цю змінну статичною та використовувати її де завгодно для шифрування та розшифрування даних.
Можливі цікаві додавання
Нижче я перерахував кілька функціональностей, які pipeline архіву активів може підтримувати для покращення розробки та загальної якості pipeline:
- Функціональність, яка дозволяє розробнику додавати та видаляти конкретні файли з архіву активів — покращує час розробки та є корисною функцією в цілому.
- Додати підтримку гарячого перезавантаження архівів активів — значно покращує час розробки та робить весь pipeline набагато кориснішим у середньому проєкті.
- Додати підтримку завантаження одного активу (зжатого/зашифрованого) з архіву активів — ймовірно, найширше використовувана реалізація такого pipeline.
- Додати Систему посилань на активи, про яку ми говорили наприкінці розділу “Контрольні суми”.
Висновок
Інтеграція pipeline архіву активів у ваш проєкт може стати потужним інструментом, особливо тому, що ви маєте додатковий контроль над даними, які хочете використовувати в своїй грі.
Як уже згадувалося раніше, вони зменшують операції введення/виведення файлів, можуть зменшити використання дискового простору, захищають ваші дані так, як ви їх запрограмували (і за допомогою методів, які ви використовуєте), але також можуть допомогти виявити проблеми, такі як пошкодження даних — все це через ваш власний проєкт.
У цій статті ми заглиблюємося в те, що складає архів активів, чому вони використовуються в іграх, але також досліджуємо реалізації, які покращують архів активів і роблять його більш "відповідним стандартам" технологій, що використовуються в деяких з ваших улюблених ігор.
На завершення я продемонструю приклад майже завершеного Pipeline архіву активів, реалізованого в моєму власному проєкті:
Демонстрація з мого університетського проєкту, який є основною причиною вмісту цієї статті
Джерела
[
Baldur's Gate 3 на Steam
Baldur's Gate 3 — рольова гра з багатим сюжетом, заснована на партійній системі в універсумі Dungeons & Dragons, де ваші вибори…
store.steampowered.com
](https://store.steampowered.com/app/1086940/BaldursGate3/?source=post_page-----c3c5199ac9d5--------------------------------)
[
Breda University of Applied Sciences | BUas.nl
Breda University of Applied Sciences; Wo- та hbo-освіта та знання в галузі ігор, медіа, готелів, обслуговування…
www.buas.nl
](https://www.buas.nl/?source=post_page-----c3c5199ac9d5--------------------------------)
[
GitHub - facebook/zstd: Zstandard - Швидкий алгоритм стиснення в реальному часі
Zstandard - Швидкий алгоритм стиснення в реальному часі. Сприяйте розробці facebook/zstd, створивши акаунт на…
github.com
](https://github.com/facebook/zstd?source=post_page-----c3c5199ac9d5--------------------------------)
[
GitHub - stbrumme/hash-library: Переносна бібліотека хешування для C++
Переносна бібліотека хешування для C++. Сприяйте розробці stbrumme/hash-library, створивши акаунт на GitHub.
github.com
](https://github.com/stbrumme/hash-library?source=post_page-----c3c5199ac9d5--------------------------------)
[
GitHub - stbrumme/crc32: Швидкий CRC32
Швидкий CRC32. Сприяйте розробці stbrumme/crc32, створивши акаунт на GitHub.
github.com
](https://github.com/stbrumme/crc32?source=post_page-----c3c5199ac9d5--------------------------------)
[
GitHub - kkAyataka/plusaes: Бібліотека шифрування AES для C++ лише з заголовками
Бібліотека шифрування AES для C++ лише з заголовками. Сприяйте розробці kkAyataka/plusaes, створивши акаунт на GitHub.
github.com
](https://github.com/kkAyataka/plusaes?source=post_page-----c3c5199ac9d5--------------------------------)
[
Криптографія
Ця стаття пояснює криптографію в архівах активів GTA SA & IV.
San Andreas використовує лише варіацію JAMCRC для…
gtamods.com
](https://gtamods.com/wiki/Cryptography?source=post_page-----c3c5199ac9d5--------------------------------)
[
Шифрування - Wikipedia
В криптографії, шифрування (точніше, кодування) — це процес перетворення інформації таким чином, щоб…
en.wikipedia.org
](https://en.wikipedia.org/wiki/Encryption?source=post_page-----c3c5199ac9d5--------------------------------)
[
Стиснення даних - Wikipedia
В теорії інформації, стиснення даних, кодування джерела або зменшення бітрейту — це процес кодування інформації…
en.wikipedia.org
](https://en.wikipedia.org/wiki/Datacompression?source=postpage-----c3c5199ac9d5--------------------------------)
[
Режим роботи блочного шифру - Wikipedia
В криптографії, режим роботи блочного шифру — це алгоритм, який використовує блочний шифр для надання інформації…
en.wikipedia.org
](https://en.wikipedia.org/wiki/Blockciphermodeofoperation?source=postpage-----c3c5199ac9d5--------------------------------#Electroniccodebook_.28ECB.29)
[
Перевірка циклічної надмірності - Wikipedia
Перевірка циклічної надмірності (CRC) — це код для виявлення помилок, який часто використовується в цифрових мережах і пристроях зберігання для…
en.wikipedia.org
](https://en.wikipedia.org/wiki/Cyclicredundancycheck?source=post_page-----c3c5199ac9d5--------------------------------)
Перекладено з: Creating an asset archive file for games (featuring compression, checksums, and encryption)