Виктор Кода дата публикации 11-10-2002 15:12 Пишем DirectX-движокПривет всем, кто интересуется DirectX!
Темой этого урока я решил сделать написание т. н. "движка". Полагая, что всегда существует определённый контингент людей, только начавших изучать мультимедийные и игровые технологии, и плавающие в загадочных понятиях, объясню, что такое "движок" и на каком бензине он работает.
"Движок" - это перевод от английского "engine" - т. е. двигатель. На данный момент термин является общепризнанным, поэтому далее я буду придерживаться именно его (хотя само слово не очень стыкуется с правилами русского языка).
Сначала небольшой экскурс в историю. Если кто помнит, в конце 80-х - начале 90-х годов игры не были такими огромными и сложными, каковыми они являются теперь. В те далёкие времена на экстишках и эйтишках в офисах гоняли разве что кошек и цветные кубики из "Тетриса". По объему кода, такие игры, естественно, не идут ни в какое сравнение с теми, что пишутся в наши дни, и поэтому программировались по-иному. Как? Обычно весь код такой программы писался за один "присест" и отвечал за всё - и за графику, и за звук (пищание), и за клавиатуру, и за AI, в общем за всё, что требовалось для воссоздания игрового процесса.
К сожалению, принцип "написать все, затем откомпилировать и это работает" проходит только для программ определенного объема, каковыми и были игры пятнадцателетней давности. Повторное использование написанного таким образом кода весьма сомнительно, и в лучшем случае вам придётся переделывать только его половину.
Вообще, одновременно с пропорциональным усложнением программ идёт пропорциональное же абстрагирование программиста от первоначального кода. Сейчас уже невозможно, например, написать полномасштабную программу на Ассемблере (вернее можно, но никто этим заниматься не будет). На помощь программисту пришли многочисленные API и библиотеки, созданные трудом многих тысяч других программистов.
Движок - это ещё один уровень абстрагирования. Можно сказать, что движок - это небольшая ОС в рамках самой игры, которая отвечает за низкоуровневые операции. В маленьких движках (если не движочках) такие функции обычно отвечают за общение с "железом" - т. е. вывод данных на экран, звуковое оформление и другие подобные вещи. Теперь программиста не интересует механизм вывода, например, текста на экран - он командует PrintText() - и все хлопоты по выводу текста берёт на себя движок. Конечно, код в движке базируется на каком-либо API, и использует его методы для реализации своих "идей". В сущности, любой API - например, DirectX - это тоже в своём роде движок, так как предоставляет нам более высокоуровневую надстройку над низкоуровневыми операциями, реализованными в DLL. Проблема в том, что такие API довольно сложны по причине их гибкости, и обеспечивают только базовые функции. Цель движка - собрать рутинные последовательности команд в один вызов. Из этого, между прочем, следует, что конкретный движок пишется под конкретный жанр игр.
Написание движков в настоящее время стало отдельным этапом разработки. Многие профессиональные фирмы уже не пишут движки самостоятельно, а приобретают их у других фирм, которые занимаются исключительно этой областью программирования. Такие движки представляют собой весьма сложные программы, они тщательно документируются и комплектуются демонстрационными примерами.
Обычно серьёзные движки отвечают не только за работу с графикой, звуком и т.д., а реализуют ещё и специфичные функции, востребованные играми жанра. Однако мы ещё не настолько "круты", что бы делать подобные вещи, поэтому займемся пока простейшими.
Я надеюсь, теперь вам понятно, что написание кода следует разбивать на отдельные этапы и постепенно реализовывать их. Движок - один из таких этапов. Реализовав этот уровень, можно приступать к написанию, скажем, самой игры. Вообще, невозможно писать игровую логику программы и одновременно заниматься поверхностями DirectDraw. Поэтапная разработка способствует тому, что проект в конце-концов будет доведён до конца, а не брошен на полпути.
Перейдём от слов к делу. Итак, с чего начать? Прежде всего надо определиться со структурой движка. Для меня это был самый сложный этап, им пренебрегают очень многие. Я на собственном опыте убедился, что если не думать над структурой, вместо цельной программы получается лапша из функций и структур. Особенно тяжеловато приходится в первый раз, когда за плечами только демонстрационные примеры и кое-какие наброски. Впрочем, иногда и лапша полезна (с точки зрения "ой, какой ужас вышел").
Меня удивляют люди, которые пишут большие программы без применения модульности. Например, почти вся реализация игры Donuts из DirectX SDK 7 размещена в одном файле. Лично у меня моментально пропало желание разбирать такую программу после того, как я пару раз повозил ползунок редактора кода туда-сюда. Подобных примеров много - взять тот же DelphiX. Всё "спихнуто" в пару файлов, и разобрать что-либо в этой лапше не представляется возможным (вообще, большие модули в Object Pascal - это болезнь самого языка, немного позже я дам объяснение этому факту).
Вывод очевиден - разработку программ необходимо вести с помощью модулей. В каждом модуле следует разместить только те функции и процедуры, которые выполняют узкий круг задач, т. е. разместить их по смыслу. Из личного опыта замечено, что желательно доводить разработку отдельного модуля до степени "готовности" приблизительно процентов этак на 60-80%. Иначе при огромном количестве "недоначататых" модулей начнётся настоящая "беготня" вокруг закладок (в случае с Delphi), и ба-альшие проблемы с отладкой кода. Только обеспечив необходимую функциональность одной части задачи, можно переходить к следующей. Естественно, всегда потом оказывается необходимым что-то исправить или дополнить в уже написанном коде, но сделать это будет гораздо легче.
Итак, перейдём к "разбору полётов". Движок (если его вообще можно так назвать), который я выставляю на всеобщее обозрение - мой первый опыт в этой области. Подумав немного, я решил написать его без применения объектно-ориентированного программирования. Хорошо это или плохо? Лично я считаю, что ООП - это хорошо, но оно не всегда востребовано. По моему сугубо личному мнению, в программах вроде игр и движках для них процедурное программирование - ничуть не устаревший инструмент, а применение ООП - неоправдано. Правильно построенная, хорошо структурированная программа легка для понимания и последующей модификации, свободная от классов, загадок наследования и ошибок полиморфизма...
Сколько я придумывал название своему движку - словами не передать. В конце концов устоялось название Simple DirectX Interface - сокращённо SDI. По примеру многочисленных библиотек (например, OpenGL) все функции движка, которые предназначены для вызова из внешней программы, начинаются с префикса "sdi", а те, что предназначены для внутреннено использования - без него.
На данный момент я реализовал только графичекую составляющую. Остальные части планируется написать немного позже. Это облегчит начинающим первое знакомство со структурой программы. Графическая часть размещается в 10 модулях, остальные выполняют различные вспомогательные работы.
Ниже дано перечисление модулей и краткое описание каждого:
- e_win.pas
- - отвечает за создание и удаление окна программы. В принципе, во внешней программе можно создавать окно самостоятельно, не прибегая к услугам этого модуля. В этом случае необходимо просто установить дескриптор имеющегося окна, вызвав функцию sdiSetHWnd().
- e_drawc.pas
- - содержит базовые функции для работы с DirectDraw - инициализация и удаление, установка полноэкранного или оконного режима работы. Дополнительно есть возможность получить минимальное описание видеоадаптера и список разрешений.
- e_draw.pas
- - здесь располагаются функции, вызываемые для построения изображения на экране.
- e_drawu.pas
- - набор вспомогательных функций, которыми пользуются остальные модули графической части движка.
- e_bmp.pas
- - организует работу по загрузке файлов формата BMP. 24-битные растры загружаются низкоуровневым способом, который уже был описан мною ранее. Палитровые файлы загружаются с помощью функции LoadImage().
- e_sprite.pas
- - функции для работы со спрайтами и текстом. При создании спрайта одновременно указывается и источник с изображением, которое должен содержать спрайт. Аналогично при создании текста указывается шрифт и сам текст.
- e_movie.pas
- - это надстройка над спрайтом. Позволяет быстро создать массив спрайтов одинакового размера и быстро загрузить в них специльным образом отредактированное изображение. Редактор прилагается.
- e_color.pas
- - предоставляет функцию sdiMakeColor() для задания, например, цветового ключа для спрайта. Т. к. формат поверхностей DirectDraw на разных видеоадаптерах и в разных разрешениях различен, значение одного и того же цвета сильно отличается для каждого случая. Используя sdiMakeColor() и указав один из 16 стандартных цветов Windows, можно избежать хлопот с некорректным цветовым ключом.
- e_pscrn.pas
- - записывает содержимое дополнительного буфера в файл BMP. Функция записи была мною несколько переработана.
- е_fps.pas
- - функция sdiGetFPS(). Выдаёт верное значение частоты смены кадров при любой скорости опроса - от 100 мс и до бесконечности.
- e_dxver.pas
- - позволяет узнать приблизительную версию DirectX. Ничего существенно нового не появилось. Модуль включён "по инерции".
- e_error.pas
- - работа с ошибками. Функция sdiGetLastError() для вывода сообщения об ошибке, происшедшей в "кишках" движка. Надеюсь, никогда не понадобится.
- e_close.pas
- - процедура sdiCloseEngine(). Вызов этой функции автоматически удаляет все ресурсы, занятые движком. По-моему, очень полезно.
- e_string.pas
- - две функции - ltos() и ltoc() для преобразования типа longint к строке string или pchar соответственно. Базируются на процедуре str() из модуля system.pas. Это здорово сокращает объём исполняемого файла по сравнению с тем, что включает в себя ссылку на sysutils.pas.
Префикс "e_" в названии модулей происходит от "engine" и предназначен для обозначения принадлежности к движку. Все модули базируются только на вызове API-функций Windows и методов интерфейсов DirectX. Это обеспечивает миниатюрность получаемого кода - динамическая библиотека (DLL), содержащая в себе весь код, после компиляции имеет размер около 50 кб (для IDE Delphi версии 5). Это значительное преимущество перед другими подобными программами, написанными на Delphi с использованием VCL (я видел exe-файлы размером 1,5 Мб).
Одно из правил классического программирования - это написание программ с наименьшим количеством глобальных переменных, т. е. их сокрытие, инкапсулирование. Я как мог, внимал этому правилу, но всё же иногда разным модулям нужно иметь доступ к одной и той же переменной или массиву. Например, интерфейс IDirectDraw7 требуется для многих функций, и он сделан видимым для всех модулей движка. В принципе, можно и сокрыть переменную внтри одного модуля, а доступ к ней обеспечить через функцию GetXXX(), но это нагружает код излишними конструкциями и в данном случае необязательно.
Рассмотрим общий механизм работы движка на основе модулей e_bmp.pas и e_sprite.pas. Так как классы не используются, обмен данными происходит через т. н. декрипторы, т. е. идентификаторы чего-либо (это напоминает механизм, на котором базируется API Windows).
Например, вот так выглядит прототип функции для загрузки BMP-файла:
function sdiLoadBmp( strFileName: string ): DWORD;
Как видно, функция возвращает как результат целое беззнаковое число. Его необходимо запомнить при вызове функции. По сути, эта функция аналогична (по принципу работы) функции GDI LoadImage(). Рузультатом работы обеих являтся идентификатор загруженного ресурса в списке уже существующих ресурсов, который можно использовать в дальнейшей работе. В нашем случае возвращается номер элемента вот этого динамического массива:
var
g_pBmp: array of SDIBMP_STRUCT = nil;
где
type
SDIBMP_STRUCT = record
pPixels: IDirectDrawSurface7;
dwWidth: DWORD;
dwHeight: DWORD;
end;
В случае ошибки функция возвращает 0, иначе любое положительное число в пределах типа longword. На самом деле возвращаемое значение всегда не 1 больше реального номера элемента в массиве, например, значение 1 будет соответствовать номеру 0 элемента массива, 2 - 1 и т.д. Это связано именно с тем, что 0 уже занят под код ошибки.
Все операции по работе с массивом g_pBmp берёт на себя функция
function FindBmp(): DWORD;
var
i: integer;
begin
if g_pBmp <> nil then
for i := 0 to high( g_pBmp ) do if g_pBmp[ i ].pPixels = nil then
begin
result := i;
exit;
end;
// первое обращение к массиву
if g_pBmp = nil then
begin
setlength( g_pBmp, 30 );
result := 0;
end else // свободный элемент в массиве не найден, в этом случае расширяем массив
begin
result := high( g_pBmp ) + 1;
setlength( g_pBmp, length( g_pBmp ) + 30 );
end;
end;
Результатом работы функции является реальный номер свободного элемента массива g_pBmp. Если массив существует в памяти, идёт поиск свободного элемента. Если массив не инициализирован, то функцией setlength() выделяется память для него и возвращается первый элемент (0). Иначе, если массив существует и свободные ячейки не найдены, необходимо расширить массив. К сожалению, при изменении длины уже существующего динамического массива сначала резервируется нужная для размещения нового массива память, затем элементы старого массива переносятся в новый, после чего освобождается память, выделенная прежнему массиву. Такие перезаёмы памяти при каждом новом изменении размера массива могут притормаживать работу программы. В данном случае это некритично, т. к. размеры одного элемента массива (и, следовательно, всего массива из этих элементов), невелики - указатель и четыре слова. Однако в более сложных случаях постоянный перезаём памяти может серьёзно "тормознуть" старт программы. Решением может служить изменение размера массива не на один элемент, а скачкообразно. Например, в моём случае - на 30 элементов сразу. Думаю, можно пожертвовать тем, что некоторая память будет постоянно заниматься напрасно, ради увеличения скорости работы.
Итак, мы получили идентификатор загруженного растра. Куда его девать? Функция
function sdiCreateSprite( bmp: DWORD; pr: Prect ): DWORD;
как раз и требует первым параметром идентификатор загруженного растра. Передав его, мы дадим ей информацию о том, какое же изображение мы хотим использовать при создании спрайта. Получив дескриптор, функция может получить описание растра:
// узнаём характеристики битовой карты
if not GetBmp( bmp, @bmps ) then
exit;
Кстати, обратите внимание, что второй параметр передаётся через указатель. Это означает, что в структуру bmps будут записаны какие-то данные. Я советую поступать именно так и не использовать служебное слово var - в этом случае с первого взглада на программу непонятно, что происходит с таким параметром - в него что-то записывается, или наоборот, он предоставляет информацию кому-то?
Получив растр и проделав свои дела, функция sdiCreateSprite() тоже возвращает идентификатор, но уже созданного спрайта. Его можно использовать, например, для вывода спрайта на экран. Для программы (например, игры) весь механизм выглядит так:
id_bmp := sdiLoadBmp( 'picture.bmp' );
id_sprite := sdiCreateSprite( id_bmp, nil );
sdiDraw( id_sprite );
Правда, просто?
Затрону немного тему инициализации и удаления. Во многих программах существуют всякие функции вроде InitEngine(), DeleteEngine() и т.п. Оказалось, что вполне можно обойтись без отдельной функции инициализации, а разместить её внутри тех функций, которые могут быть вызваны первыми и требовать какие-то объекты для себя. Например, функция
function sdiEnumVideoModes( pvma: PSDIVIDEOMODEARRAY ): boolean;
для перечисления видеорежимов самостоятельно вызывает функцию инициализации DirectDraw, если это событие ещё не произошло:
if (not g_bInitDirectDraw) and (not InitDirectDraw()) then
exit;
В данном случае g_bInitDirectDraw - глобальная переменная-флаг, сообщающая, инициализирован объект DirectDraw или нет.
Таким образом, нет нужды что-либо инициализировать из внешней программы. Правды ради надо сказать, что в гораздо более крупных программах этот шаг, наверное, всё же будет необходим, ибо делать автоматическую инициализацию для каждой функции движка накладно, легче проделать это один раз в стандартном порядке. Впрочем, ответить на вопрос, как поступать в этом случае, можно лишь самостоятельно написав что-либо фундаментальное.
Единственный вызов, который должен присутствовать - это sdiCloseEngine(). Этим вызовом мы удаляем все занятые движком ресурсы. Впрочем, кое-что можно удалить и явно, например
procedure sdiDestroyWindow();
Отдельный вопрос - это реализация механизма вывода сообщений об ошибках. Фантазии авторов программ здесь простираются от банального "Ошибка в программе" до "Тут длинная и интересная история об ошибке в файле таком-то, строка такая-то, код ошибки DDERR_ТАКАЯ_ТО_АББРЕВИАТУРА. Application will now exit.". Авторы программ, похоже, всерьёз задумываются над тем, как бы приукрасить окошко с ошибкой самой детальной информацией. Надо молиться, чтобы такое окошко никогда не всплыло вообще!
Я решил ограничиться простым текстовым сообщением об ошибке. Функция sle() предназначена для её установки единственный раз. Если же она будет вызвана повторно (например, на более высоком уровне), запись не произойдёт:
procedure sle( str: string );
begin
if bBuildLog then
WriteErrorToLogFile( str );
if not bAlreadySetLastError then
begin
strError := str;
bAlreadySetLastError := true;
end;
end;
Это гарантирует, что мы получим описание настоящей ошибки, а не ёё последствия. А вот лог-файл программы желательно должен содержать все сообщения об ошибках (для простоты "охоты" за ними). Также лог-файл обычно содержит описание всех произошедших действий, но я пока не реализовал это.
Для правильного контроля в идеале необходимо проверять КАЖДУЮ вызываемую функцию на возвращаемый результат, будь то метод DirectX или функция GDI. Это повышает гарантию того, что программа, например, не "вылетит" тихо в Windows или не допустит ошибок вроде AV. Я пытался следовать этому правилу как мог, но всё же не стоит усердствовать над IDirectDrawSurface7.Unlock() или DeleteDC(). Заметьте, что я совсем не использую популярные у некоторых программистов блоки try..except.
По-моему, легче проверить делитель на ноль, чем делить вслепую и потом смотреть, что получилось. Из личного опыта замечено, что с помощью try..except не всегда можно избежать краха программы, в частности иногда ошибка AV неминуема.
На первом этапе работы можно контролировать только наиболее критичные участки кода. Когда движок будет практически завершён, можно "навесить" таких обработчиков побольше. Тут, как говорится, можно дать волю рукам - жёсткий контроль на каждом этапе только уменьшает шансы на принудительную остановку программы операционной системой. Вообще, контроль ошибок может быть серьёзно расширен вплоть до определения характеристик оборудования - например, DirectDraw позволяет провести опрос характеристик видеокарты. Впрочем, выделка должна стоить овчинки.
Причина, по которой я так долго не выставлял материалы в Королевство - это попытка реализовать собственные эффекты. Например, изначально движок мог выводить полупрозрачные спрайты, уже описанные мною в предыдущий раз, а также масштабировать изображение и осуществлять поворот спрайта (путём "прямого" доступа к поверхноти DirectDraw). Однако скорость вывода оказалось настолько мала, что я в конце-концов вырезал всё это из кода. Получилась смешная ситуация - достаточно быстрый акселератор вроде GeForce 2MX 400 выдавал просто неприличный fps при повороте спрайта размером 256*256 пикселей. Могу посоветовать только одно - не пытайтесь сделать с помощью DirectDraw какие-либо эффекты. Аппаратно они попросту не поддерживаются ни одной видеокартой (например, поворот на произвольный угол), а если сделать всё вручную, то скорость вывода попросту очень низкая.
Я написал пару тестовых примеров, призванных показать общую работу с движком. Вот какой список uses получается при подключении всех файлов движка этими примерами:
uses
windows,
messages,
// файлы движка
e_win, e_drawc, e_draw, e_drawu, e_bmp, e_sprite, e_movie, e_color, e_pscrn, e_fps,
e_dxver, e_error, e_close, e_string;
Как видите, немаленький. Размещение всего кода в динамической библиотеке и подключение единственного заголовочного файла для работы с ней решает проблему, но это не очень красиво. Обычно поступают таким образом - весь код на последнем этапе разработки "спихивается" в один или несколько модулей, и список uses уменьшается. Например, описание API DirectX 6 от Хироюки Хори располагается в одном модуле DirectX.pas (в то время как SDK от Microsoft содержит в папке include десятки отдельных файлов). После такого "решения проблемы" программа становится трудно модифицируемой.
В языках C и C++ такая ситуация не возникает - для этого можно создать отдельный модуль, например, sdi.h, и подключить в нём все необходимые файлы:
#include "e_win.h"
#include "e_drawc.h"
#include "e_drawu.h"
...
#include "e_string.h"
Теперь программа станет "видеть" весь код в этих файлах после подключения единственного файла sdi.h. К сожалению, язык Object Pascal до сих пор не поддерживает такое "неявное" подключение модулей, поэтому разработка действительно больших Проектов на этом языке всегда будет сопровождаться огромным списком uses или, наоборот, огромными модулями. Если кто-то знает, как решить эту проблему, автор будет очень благодарен за совет. Возможно, единственным приемлемым решением являются всё же DLL.
Несколько слов о недоработках.
Первое: пример MainExample в окне в видеорежимах HighColor или TrueColor на всех компьютерах с видеокартами GeForce2 MX 400, где я его тестировал, почему-то работает некорректно. Наблюдается странное поведение всей операционной системы в виде общего замедления работы. Это можно было бы со злорадством отнести к ошибкам движка, НО:
- На видеокарте S3 Trid3D/2X движок работает нормально в любом режиме!
- На видеокарте GeForce всё работает нормально в режиме 256 цветов!
Вот так. Самое удивительно - примеры из MS SDK работают у меня корректно на обеих видеокартах. Вообще, когда такая ошибка обнаружилась, я был в большом недоумении и полностью растерян. Для "чистоты эксперимента" я даже написал отдельную программу (не на движке), которая также работала в оконном режиме. К сожалению, и она работала некорректно, а ведь программный код был минимален и ошибке попросту негде было спрятаться. Поразмыслив, я пришёл к выводу, что имеет место некорректное взаимодействие программ, написанных на Delphi и драйверов Detonator. Звучит дико, но других объяснений я не нахожу.
Второе: я переделал функцию сохранения изображения в файле, теперь она работает корректно для видеокарты S3. К сожалению, на GeForce в 16-битовом режиме она получается искажённой, причину я так не нашёл. Для режима 32 бита всё работает правильно.
Мысли вслух:
- Если планируется писать какую-то игру или мультимедийное приложение, лучше написать сначала движок для неё.
- Разработка более-менее крупной программы после маленьких развязывает руки, позволяет "развернуться" программисту, реализовать некоторы свои амбиции.
- Вместе с тем работа по ловле ошибок довольно хлопотна и иногда раздражает.
- Уделите некоторое внимание организации вывода сообщений об ошибках - это окупится сторицей в процессе разработки.
- Иногда встречаются никак, совершенно, ну абсолютно необъянимые "bugs"! Это может здорово испортить жизнь.
- Иногда (только иногда) такие баги пропадают сами собой, если их "заморозить" недельки на две заглушкой, а потом снять %)
- Собственная(!) реализация всяких эффектов вроде прозрачности и поворота радует глаз, однако слишком уж они медленны и неказисты, их качество часто желает оставлять лучшего. Вдобавок, DirectDraw API постепенно становится устаревшей технологией, её развите корпорацией Microsoft уже давно остановлено. Возможно, захотев иметь в своей программе красочные спецэффекты, следует обратить своё внимание на двумерное рисование посредством таких API, как Direct3D и OpenGL.
Скачать примеры: DXEngine.zip (141 K)
Виктор Кода
Специально для Королевства Delphi
[DirectX, DirectShow etc.]
Обсуждение материала [ 05-06-2010 01:05 ] 13 сообщений |