Пишем DirectX-движокИсточник: delphikingdom Виктор Кода, королевство Delphi
Автор: Виктор Кода, королевство DelphiПривет всем, кто интересуется 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_" в названии модулей происходит от "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. К сожалению, на GeForce в 16-битовом режиме она получается искажённой, причину я так не нашёл. Для режима 32 бита всё работает правильно. Мысли вслух:
|