Способы хранение графики в играх и бизнес приложениях
Содержание
Введение
Возможная структура файла
"Компилятор"
"Загрузчик"
Хранение ресурса в рекции PE файла
Играем MP3 из EXE
Список ссылок
Введение
В предыдущей статье я рассказал, как можно считывать
растры напрямую из файла ( надеюсь информация оказалась для Вас интересная). Теперь
поговорим о том, как создать собственный, удобный для нас, формат хранения графической
информации. Рассматриваемый подход пригодится не только для хранения графики,
но и для совершенно различных бинарных данных. Это могут быть и музыкальные треки
в популярном формате MP3, видео фрагменты, текстовые данные - в общем, любые данные
Вашего приложения.
В конце предыдущей статьи, я изложил краткий алгоритм для создания таких файлов,
теперь я попытаюсь последовательно описать все его тонкости.
Возможная структура файла
Для начала набросаем приблизительную структуру нашего будущего формата данных.
Для примера, я создам файл для хранения обычных растров в не компрессированном
виде.
FileHeader
TGameRusourceHeader
|
Name |
String[32] |
Название файла |
Version |
Integer |
Версия |
ImageCount |
Integer |
Количество изображений |
|
ResourceTable
TGameResourceTable
|
ResName |
String[32] |
Название ресурса |
Offset |
Integer |
Смещение от начала файла |
|
ResourceData
|
Графические данные
|
Так выгладит заголовок файла. Количество элементов ResourceTable соответствует
количеству хранимых изображений. Сразу за последней записью в ResourceTable начинаются
данные изображений.
Для создания подобного форматы нам потребуется написать небольшой "компилятор"
или скорее "сшиватель" ресурсного файла. В его задачи будет входить создание файла
с описанным выше форматом из обычных BMP файлов. Сразу стоит оговорится, что создание
компилятора или упаковщика (это кому как нравится) самый трудоемкий процесс. По
этому я решил особо не выпендриваться и написать его с использованием VCL, т.к.
во первых это сугубо рабочая утилита, кроме нас её никто видеть не будет, а во
вторых тут совсем не принципиальна скорость работы - один раз собрали ресурс и
забыли про него. Хотя для больших работ, когда суммарный объем обрабатываемых
файлов переваливает за сотни МБ, стоит подумать и о оптимизации.
Компилятор
Начнем создание компилятора с заготовки необходимых структур:
TGameRusourceHeader= packed record
Name : string[32];
Version : Integer;
ImageCount : Integer;
end;
TGameResourceTable = packed record
ResName : string[32];
Offset : Integer;
end; |
Надеюсь тут все понятно. Есть только две небольшие тонкости. Первая нельзя
использовать просто String - только фиксированную длину строки! Иначе SizeOf(TGameRusourceHeader)
выдаст совершенно не верный результат. Вторая тонкость по организации проекта.
Т.к. нам нужно написать и компилятор ресурсов и загрузчик, лучше вынести описание
заголовков в отдельный модуль.
Как уже писалось выше, я буду использовать VCL компоненты и стандартный набор
классов. Это сильно сократит исходный код, да и сделает его понятным. Алгоритм
процедуры "сшивания" ресурсов следующий. Процедуре будем предавать список файлов,
и название выходного файла.
В переменные заведем три потока и другие необходимые переменные:
procedure TDIBCompilerForm.CompileResource(const SourceFileList:TStringList;
const OutFileName: string);
var
// Поток для записи окончательного файла
OutStream : TStream;
// Поток для хранения таблицы смещений
TableStream : TStream;
// Поток для хранения данных
ResourceStream : TStream;
// Кол-во изображений
ImgCount : Integer;
// Смещение от начала файла текущее растра
Offset : Integer;
// Размер заголовка и размер всей таблицы смещений
HeaderSize : Integer;
AllTableSize : Integer;
// Экземпляр для записи в таблицу смещений
GameResourceTable : TGameResourceTable;
... |
Для начала работы процедуры проинициализируем все объекты и переменные:
...
OutStream:=TFileStream.Create(OutFileName, fmCreate);
TableStream:=TMemoryStream.Create;
ResourceStream:=TMemoryStream.Create;
ImgCount:=1;
HeaderSize:=SizeOf(TGameRusourceHeader);
AllTableSize:=SizeOf(TGameResourceTable)*SourceFileList.Count;
Offset:=HeaderSize+AllTableSize;
... |
Самой сложной частью процедуры я считаю рассчет смещений, все остальное достаточно
прозрачно:
for I:=0 to SourceFileList.Count-1 do
begin
Bitmap.LoadFromFile(SourceFileList[I]);
Bitmap.SaveToStream(ResourceStream);
GameResourceTable.ResName:=ExtractFileName(SourceFileList[I]);
GameResourceTable.Offset:=Offset;
...
TableStream.WriteBuffer(GameResourceTable, SizeOf(TGameResourceTable));
Offset:=HeaderSize+AllTableSize+ResourceStream.Position; Inc(ImgCount);
end; |
... на последок копирование данных в выходной поток и очистка занятых ресурсов.
// Перемещаемся на начало данных
ResourceStream.Seek(0, soFromBeginning);
TableStream.Seek(0, soFromBeginning);
// Устанавливаем количесво добавляемых битмапов в заголовок
GameRusourceHeader.ImageCount:=ImgCount;
// Запись выходного файла
OutStream.WriteBuffer(GameRusourceHeader, SizeOf(GameRusourceHeader));
OutStream.CopyFrom(TableStream, TableStream.Size);
OutStream.CopyFrom(ResourceStream, ResourceStream.Size);
// Блок финализации
ResourceStream.Free;
TableStream.Free;
OutStream.Free;
Bitmap.Free; |
Вот собственно и всё. После успешного завершения процедуры у Вас получится
файл с описываемой структурой. При работе процедуры, создается файл с расширением
OutFileName.text, куда записывается вся информация о размерах структур, смещениях
и т.д. Смещения записываются как в обычном десятичном виде, так и в шестнадцатеричной
форме. Последняя форма записи очень помогает при анализе полученного файла в любом
HEX редакторе (WinHex, Hview и т.д.).
Не возможно не упомянуть об одной особенности - уменьшении размера полученного
файла. Поясню более подробно. Для примера я скомпилировал набор из 313 BMP файлов
различного размера. Суммарный объем файлов 2, 359 Кб, после сборки получился файл
размером 2,428 Кб - оно и понятно, мы записываем лишнею информацию. После сжатия
архиватором ZIP отдельных BMP файлов получился архив размером 697 Кб, а вот при
сжатии выходного файла - 640 Кб. Выигрыш очевиден, причем он растет с увеличением
числа хранимых битмапов и уменьшения их размера. При сборке ~500 картинок размером
16x16 выигрыш получается более чем в два раза. Необходимо помнить, что для приложений
распространяемых по сети размер дистрибутива до сих пор критичен. И если Ваша
игра или утилитка "весит" в 5-6 раз меньше, чем аналоги, шанс что пользователь
выберет именно её повышается не однократно.
"Загрузчик"
Надеюсь с созданием формата данных для хранения информации Вы разобрались,
теперь осталась самая легкая часть - написать загрузчик графики из нашего формата.
Как и с компилятором ресурсов, я напишу программу используя VCL (ну не знает наш
народ API, а при виде одного dpr файла впадает в ступор - "А где же форма ? Где
мой любимы TButton.OnClick ???"=) ).
Всё действо будет происходить в одной процедуре. Параметр ResourceFileName
- путь и имя к файлу, а ImageCount - номер изображения для загрузки (нумерация
начинается с 1). В процедуре нам понадобится всего четыре переменные:
procedure TLoaderForm.LoadBitmap(const ResourceFileName: string;
const ImageCount:integer);
var
FileStream : TStream;
Bitmap : TBitmap;
GameRusourceHeader: TGameRusourceHeader;
GameResourceTable : TGameResourceTable; |
Сама процедура чрезвычайно проста, обратите внимание только на получение смещение
для загрузки файла:
// Инициализация
Bitmap:=TBitmap.Create;
FileStream:=TFileStream.Create(ResourceFileName, fmOpenRead);
// Чтение заголовка
FileStream.ReadBuffer(GameRusourceHeader, SizeOf(GameRusourceHeader));
// Перемещение на начало таблицы смещений ресурса
FileStream.Seek((ImageCount-1)*SizeOf(GameResourceTable), soFromCurrent);
// Чтение таблицы ресурса
FileStream.ReadBuffer(GameResourceTable, SizeOf(GameResourceTable));
// Перемещение к началу данных затребованного ресурса
FileStream.Seek(GameResourceTable.Offset, soFromBeginning);
// Непосредственная загрузка
Bitmap.LoadFromStream(FileStream);
...
// Убираем за собой
Bitmap.Free;
FileStream.Free; |
Процедура работает практически мгновенно, я имею в виду перемещение по файлу,
а за скорость загрузки самого изображения ответственность ложится на метод LoadFromStream.
Возможно, я приложу к статье пример, показывающий, как можно избежать использования
TBitmap и загружать ресурс самостоятельно. Хотя это совсем не сложно сделать объединив
материал предыдущей статьи и приведенный выше код.
Остановимся на возможности оптимизации. Итак:
- Первая возможность оптимизации заключается в кэшировании таблицы смещений.
Такая структура занимает не очень много места в памяти, но позволит совершить
мгновенный переход не только по индексу изображения (что не удобно), но и по его
имени, если оно конечно хранится.
- Вторая - использование собственного загрузчика изображений. Это позволит
выиграть очень много времени, особенно если использовать оптимизированные процедуры
для загрузки 8 и 24-х битных изображений.
Защищенность ресурса от просмотра ниже средней - простой человек не посмотрит,
а для программиста средней руки разобрать такой формат раз плюнуть. Но захочет
ли он с этим возится?
Хранение ресурса в секции PE файла
Теперь настала пора разобраться, как поместить созданный ресурсный файл в исполняемое
приложение, т.е. просто "вшить" его в exe файл. Проблемы с соединением
не возникнет, а вот как с обращением к ресурсу стоит попотеть.
Как известно в PE файле есть различные секции, при этом ничего не мешает Вам
писать в секцию импорта свои данные, но есть специальная секция RCData. Она то
и предназначенная для записи собственных данных приложения, т.е. в неё можно пихать
всё, что угодно (в смысле бинарных данных :)), в разумных пределах конечно.
Для примера я создам файл out (с помощью описанного ранее компилятора) содержащий
четыре 24-х битных растра. Количество не имеет значение, а 24-битные растры я
буду помещать по тому, что их проще загружать.
Итак создаем RC файл, например Resource.rc со следующим содержанием:
GAMEDATA - название ресурса, т.е. его идентификатор;
RCDATA - название секции;
Out - имя файла, может быть только название файла, а может и целый путь.
Создаем ресурсный файл вызывая компилятор ресурсов:
...ааа вот зачем программистам в Windows нужна командная строка! После успешного
завершения, мы получим бинарный ресурсный файл Resource.RES, его можно подключать
к проекту директивой компилятора:
Теперь при сборке проекта в получившемся exe файле появится секция RCDATA и
в ней ресурс с названием GAMEDATA.
Осталось совсем чуть-чуть - написать процедуру загрузки. Она будет достаточна
сложна, и если Вы не сильны в таких понятиях как указатели, дескрипторы, плоская
модель памяти … смело пропускайте данный материал. Бездумное копирование кода
до добра не доводит :)
Начнем как всегда с расшифровки переменных:
procedure TDibFromResForm.LoadBitmap(const ImageCount:Integer);
var
// Указатели на структуры заголовка файла
GameRusourceHeader : PGameRusourceHeader;
GameResourceTable : PGameResourceTable;
// Указатели на структуры растра
BitmapFileHeader : PBITMAPFILEHEADER;
BitmapInfoHeader : PBITMAPINFOHEADER;
BitmapInfo : TBitMapInfo;
BitmapBits : Pointer;
// Handle заголовок данных блока ресурса
RSRC : HRSRC;
// Handle на область памяти ресурса
RES : THandle;
// Указатель на область памяти загруженного
ресурса
P : Pointer;
// Переменная для хранит начально адреса данных
ресурса
StartAddr : Integer;
// Счетчик смещения адресов
I : Integer;
// Переменная для хранения полученного битмапа
Bitmap : HBITMAP;
// Контекст устройства
DC : HDC;
... |
Ну как, не испугались ? Дальше интереснее будет:
RSRC:=FindResource(HInstance, 'GAMEDATA', RT_RCDATA);
if RSRC = 0 then
begin
MessageBox(Handle,'Ресурс не найден.', MessageTitle, MB_ICONERROR+MB_OK);
Exit;
end;
RES:=LoadResource(HInstance, RSRC);
P:=LockResource(RES); |
Находим ресурс функцией FindResource по его идентификатору GAMEDATA в секции
RT_RCDATA. Загружаем ресурс функцией LoadResource, блокируем доступ к нему и получаем
область занимаемой им памяти в указатель P.
StartAddr:=Integer(P);
I:=StartAddr;
GameRusourceHeader:=Ptr(I);
Inc(I, SizeOf(TGameRusourceHeader));
Inc(I, SizeOf(TGameResourceTable)*(ImageCount-1));
GameResourceTable:=Ptr(I);
I:=StartAddr+GameResourceTable.Offset;
BitmapFileHeader:=Ptr(I);
Inc(I, SizeOf(TBitmapFileHeader));
BitmapInfoHeader:=Ptr(I);
Inc(I, SizeOf(TBitmapInfoHeader));
GetMem(BitmapBits, BitmapInfoHeader.biSizeImage);
BitmapBits:=Ptr(I);
BitmapInfo.bmiHeader:=BitmapInfoHeader^; |
В StartAddr получаем адрес памяти указателя P и устанавливаем счетчик I на
это же значение. Далее все опрерации будем производить только со счётчиком, так
нагляднее. Загружаем заголовок GameRusourceHeader, он находится прамо по адресу
I или StartAddr, т.к. в самом начале блока памяти загруженного ресурса.
Увеличиваем счетчик на размер структуры TGameRusourceHeader. Параметр функции
ImageCount хранит номер растра, который необходимо получить. По этому высчитываем
смещение для требуемой таблицы ресурсов: SizeOf(TGameResourceTable)*(ImageCount-1).
Получаем таблицу смещений. Из неё можно вытащить смещение требуемого растра: StartAddr+GameResourceTable.Offset.
По этому смещению можно последовательно считать BitmapFileHeader, BitmapInfoHeader
и BitmapBits. Вот собственно и все!
Осталось создать Bitmap и очистить ресурсы:
...
Bitmap:=CreateDIBitmap(DC, BitmapInfoHeader^, CBM_INIT, BitmapBits, BitmapInfo,
DIB_RGB_COLORS);
...
UnlockResource(RES);
FreeResource(RES); |
Не так все и сложно, хотя я представляю лица ( масли, выражения, жесты ...
) тех, кто сел за Delphi месяц назад :))) На самом деле, достаточно сложный для
понимания материал. Хотя если Вы изучали C или ASM для Вас должно быть всё тривиально.
Пример приведённый выше далеко не оптимален:
- Во первых, присутствует несколько лишних переменных. Это сделано только для
большей понятности кода.
- В коде всего одна проверка - это очень не правильно :) Нужно сопровождать
каждую операцию проверками на нулевой указатель, правильность размера структуры
и т.д. Самая простая - на существование запрошенного изображения, а то может получится
такая ситуация, что всего в ресурсе 10 растров, а запрашиваем 1001. Процедура
попытается читать данные из совсем "левой" области памяти. Мы ведь работаем
с нетипизированными указателями, напрямую с памятью и ошибки вызовут крах всего
приложения.
Еще хочется остановится на такой проблеме как хранение музыки
и другой мультимедиа информации в exe файле. После прочтения выше изложенного
материала проблем с этим возникнуть не должно. Я попробую изложить несколько общих
принципов, а конкретная реализация зависит от того какие ресурсы Вы хотите поместить
в файл и какие методы воспроизведения будите использовать.
Общий алгоритм такой - помещаем каждый из ресурсов в секцию RCDATA, присваивая
каждому уникальное имя (идентификатор). После этого получаем указатель на начало
блока памяти занимаемого ресурсом и выполняем воспроизведение с помощью соответствующих
функций.
Приведу небольшой пример. Допустим, необходимо подключить к exe файлу композицию
в формате MP3. Для маленьких игр это может быть музыкальный фон, звуки спец. эффектов
и т.д. Файл будет называться sample.mp3
Создадим ресурсный файл MusicRec.RC и в него добавим строчку:
Соберём бинарный файл ресурса командой:
и подключим к нашему приложению скомпилированный ресурсный файл:
После этих операций в нашем exe файле будет присутствовать MP3 фрагмент sample
c идентификатором MUSIC1.
Нам осталось только проиграть данный файл. Что для этого потребуется ? Конечно
проигрыватель. Все конечно же подумали про WinAMP, но это же проигрыватель внешних
файлов, к тому же не интересно привязывать нашу программу к WinAMP'у.
Среди свободно распространяемых проигрывателей я выбрал библиотеку BASS. Во
первых она позволяет проигрывать не только MP3 файлы, но и файлы трекерных форматов
XM, MOD и т.д., что очень актуально для игр и демонстраций. Ведь эти файлы занимают
очень мало места, а качество музыки на очень приличном уровне. Еще один большой
плюс библиотеки BASS - её бесплатность для не коммерческого использования. К тому
же она распространяется в виде динамической библиотеки с открытым интерфейсом
и очень проста в использовании - я буквально за 10 минут написал этот пример,
при этом ранее библиотеку никогда не видел.
Собственно работа со звуком заключается в 4-х операциях:
Инициализация биьлиотеки.
Загрузка звукового фрагмента.
Воспроизведение.
Освобождение ресурсов.
Нам интересен кусочек загрузки музыкального фрагмента из памяти, по этому я
рассмотрю только его.
var
RSRC : HRSRC;
RES : THandle;
P : Pointer;
...
RSRC:=FindResource(HInstance, 'MUSIC1', RT_RCDATA);
...
RES:=LoadResource(HInstance, RSRC);
P:=LockResource(RES);
Music:=BASS_SampleLoad(TRUE, P, 0, MusicSize, 3, BASS_SAMPLE_OVER_POS);
... |
В качестве основных параметров, процедуре BASS_SampleLoad передается указатель
P и размер музыкального фрагмента в байтах (размер описывается к константах).
Значение остальных параметров описаны в файле bass.pas или в файле справки.
Для лучшего осмысления работы библиотеки BASS посмотрите пример BassTest входящий
в комплект поставки.
Вот собственно и всё. В следующий части статьи я поробую рассказать о хранении
компрессованных ресурсов.
Список ссылок
Адрес автора |
|
Исходные тексты примеров |
|
Статья "Обзор формата DIB и компонентов для работы с
ним" |
|
Статья "Низкоуровневая загрузка растра" |
|
Сайт библиотеки BASS |
|
|