Пишемо 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>

*

*