Пишемо DirectX-движок

Автор: Віктор Коду, королівство 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_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, де я його тестував, чомусь працює некоректно. Спостерігається дивна поведінка всієї операційної системи у вигляді загального уповільнення роботи. Це можна було б зі зловтіхою віднести до помилок движка, АЛЕ:

  1. На відеокарті S3 Trid3D/2X движок працює нормально в будь-якому режимі!
  2. На відеокарті GeForce все працює нормально в режимі 256 кольорів!
Ось так. Саме дивно – приклади з MS SDK працюють у мене коректно на обох відеокартах. Взагалі, коли така помилка виявилася, я був у великому здивуванні й повністю розгублений. Для "чистоти експерименту "я навіть написав окрему програму (не на движку), яка також працювала у віконному режимі. На жаль, і вона працювала некоректно, але ж програмний код був мінімальний і помилку просто ніде було сховатися. Поміркувавши, я прийшов до висновку, що має місце некоректне взаємодію програм, написаних на Delphi і драйверів Detonator. Звучить дико, але інших пояснень я не знаходжу.

Друге: Я переробив функцію збереження зображення у файлі, тепер вона працює коректно для відеокарти S3. На жаль, на GeForce в 16-бітовому режимі вона виходить спотвореної, причину я так не знайшов. Для режиму 32 біта все працює правильно.

Думки вголос:

  1. Якщо планується писати якусь гру або мультимедійне додаток, краще написати спочатку движок для неї.
  2. Розробка більш-менш великої програми після маленьких розв'язує руки, дозволяє "розвернутися" програмісту, реалізувати деякі свої амбіції.
  3. Разом з тим робота з ловлі помилок досить клопітка і іноді дратує.
  4. Приділіть належну увагу організації виведення повідомлень про помилки – це окупиться сторицею в процесі розробки.
  5. Іноді зустрічаються ніяк, зовсім, ну абсолютно необ'янімие "bugs"! Це може здорово зіпсувати життя.
  6. Іноді (тільки іноді) такі баги пропадають самі собою, якщо їх "заморозити" тижнів на два заглушкою, а потім зняти%)
  7. Власна (!) Реалізація всяких ефектів на кшталт прозорості і повороту радує око, однак занадто вже вони повільні і непоказні, їх якість часто бажає залишати кращого. До того ж, DirectDraw API поступово стає застарілою технологією, її развите корпорацією Microsoft вже давно зупинено. Можливо, захотівши мати у своїй програмі барвисті спецефекти, слід звернути свою увагу на двовимірне малювання за допомогою таких API, як Direct3D і OpenGL.

Схожі статті:


Сподобалася стаття? Ви можете залишити відгук або підписатися на RSS , щоб автоматично отримувати інформацію про нові статтях.

Коментарів поки що немає.

Ваш отзыв

Поділ на параграфи відбувається автоматично, адреса електронної пошти ніколи не буде опублікований, допустимий HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

*

*