Способы хранение графики в играх и бизнес приложениях
Введение
В предыдущей статье я рассказал, как можно считывать растры напрямую из файла ( надеюсь информация оказалась для Вас интересная). Теперь поговорим о том, как создать собственный, удобный для нас, формат хранения графической информации. Рассматриваемый подход пригодится не только для хранения графики, но и для совершенно различных бинарных данных. Это могут быть и музыкальные треки в популярном формате 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
…
GAMEDATA - название ресурса, т.е. его идентификатор;
RCDATA - название секции;
Out - имя файла, может быть только название файла, а может и целый путь.
Создаем ресурсный файл вызывая компилятор ресурсов:brcc32.exe Resource.rc
...ааа вот зачем программистам в Windows нужна командная строка! После успешного завершения, мы получим бинарный ресурсный файл Resource.RES, его можно подключать к проекту директивой компилятора:{$R 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 и в него добавим строчку:MUSIC1 RCDATA sample.mp3
Соберём бинарный файл ресурса командой:brcc32.exe MusicRec.RC
и подключим к нашему приложению скомпилированный ресурсный файл:{$R MusicRec.RES}
После этих операций в нашем 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 входящий в комплект поставки.
Вот собственно и всё. В следующий части статьи я поробую рассказать о хранении компрессованных ресурсов.
Источник: http://www.delphigfx.narod.ru