Способы хранение графики в играх и бизнес приложениях

Автор статьи: Мироводин Дмитрий
Сайт Автора: [email protected]
E-mail Автора: delphigfx.mastak.ru
Дата публикации: 25.11.2005

 Содержание

Введение
Возможная структура файла
"Компилятор"
"Загрузчик"
Хранение ресурса в рекции 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 и загружать ресурс самостоятельно. Хотя это совсем не сложно сделать объединив материал предыдущей статьи и приведенный выше код.

Остановимся на возможности оптимизации. Итак:

  1. Первая возможность оптимизации заключается в кэшировании таблицы смещений. Такая структура занимает не очень много места в памяти, но позволит совершить мгновенный переход не только по индексу изображения (что не удобно), но и по его имени, если оно конечно хранится.
  2. Вторая - использование собственного загрузчика изображений. Это позволит выиграть очень много времени, особенно если использовать оптимизированные процедуры для загрузки 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 для Вас должно быть всё тривиально.

Пример приведённый выше далеко не оптимален:

  1. Во первых, присутствует несколько лишних переменных. Это сделано только для большей понятности кода.
  2. В коде всего одна проверка - это очень не правильно :) Нужно сопровождать каждую операцию проверками на нулевой указатель, правильность размера структуры и т.д. Самая простая - на существование запрошенного изображения, а то может получится такая ситуация, что всего в ресурсе 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 входящий в комплект поставки.

Вот собственно и всё. В следующий части статьи я поробую рассказать о хранении компрессованных ресурсов.