Pravimo game engine od nule, deo 1. — kako napraviti prazan crni prozor?

Zašto pravimo game engine iz početka? Zato što možemo. I zato što je bitno da se trudimo da uvek saznamo više. Ovo je prvi deo u serijalu koji za vas i nas sprema Miroslav Gavrilov.

Miroslav Gavrilov - 16. Jul, 2015.

Zašto bi neko hteo da napravi svoj game engine? Zar nisu Unity, Unreal i ostali mnogo bolji od bilo čega što možemo napraviti? Pa, da, jesu.

Ljudi koji su ih pravili, doduše, nisu samo odjednom znali kako se prave kompleksne softverske mašinerije, već su se tome naučili vremenom. Naučili su tako što su uzeli da ih prave. Srbija ima malo ljudi koji se ovim bave, no, moje je mišljenje da je bitno da se trudimo da uvek saznamo više. Da možda i možemo da napravimo nešto novo i drugačije, ako znamo kako staro i oprobano radi. Ako ne uzmemo ni da čačkamo, uvek ćemo ostati na istome.

U nedostatku bolje reči, disklejmer, #1: ovo je serijal koji je prvenstveno tu kao opšte uputstvo za ulaženje u C++ i razvoj igara (ili njihovih delova) u DirectX-u ili OpenGL-u. Ostavljamo otvorena vrata za mnoge izmene i detalje, no pokušaću da predstavim amalgamaciju iskustava i zanimljivih tehnika skupljenih tokom godina. Poseban akcenat se stavlja na generalizaciju i lako omogućavanje proširenja, po cenu brzog i lakog završavanja teksta. Ono što je sigurno je da će kraj svakog dela ovog serijala biti radna verzija, više ili manje upotrebljiva, no, sa, nadajmo se, ogromnom edukativnom vrednošću.

Disklejmer #2: u sklopu ovog tutorijala, naš engine će se zvati SoosEngine, kako bi se teško mešao sa bilo čim. Soos može da bude skraćeno od Startit owns our souls, što se mene tiče. Za ikonicu koristim dragog mi lika (Soos Ramirez), čisto zbog sličnosti u imenu. Ne držim prava na tu sliku ili bilo šta drugo u vezi sa Gravity Falls serijalom (gledajte ga, odličan je).

Usput, ovo nije C++ tutorijal za početnike, iako vam hoće pomoći da, uz neprespavane noći, naučite neke trikove i ne-tako-očigledne stvari iz ovog divno zapetljanog jezika.

Praviti game engine nije laka rabota.

To je možda jedna od najtežih stvari koje možete isprogramirati, jer, u zavisnosti od onoga što želite da postignete, sadrži potrebu za veoma brzim izvršavanjem (te moramo biti svesni detalja koje bismo u običnim aplikacijama mogli da zanemarimo), gomilom stvari koje se dešavaju odjednom, pisanjem svog ili ubacivanjem nekog tuđeg skripting jezika u već komplikovanu stvar, rada sa različitim hardverom, pisanjem koda koji može da bude generalan i primenjiv na različite stvari, itd.

Praviti dobar game engine je užasno teška rabota. SoosEngine je namenjen edukaciji, no u isto vreme, u nekom momentu teži postati i samo-održiv, dobar, brz, čist 2D engine za sve i svašta. Ko ima strpljenja, videće i kako je došlo do toga, a ostali će pazariti jednu od brojnih budućih licenci i igrati se sa njim vikendom. Između ostalog, iznenadno kao iznenadni testovi u osnovnoj školi, imaćemo nedelje kad nećemo praviti engine, već nešto u njemu, pa se i tome možete radovati.

Pošto hoćemo da pravimo generalni game engine za ovu priliku, želimo nešto što će raditi na većini sistema, sa većinom trenutno korišćenih grafičkih (i drugih) biblioteka. Stoga je dobra ideja u glavnom programu imati što manje toga. Nešto ovakvo bi bilo savršeno na Windows mašini, recimo, ako hoćemo da koristimo DirectX:


// Main.cpp
#include "SoosEngineDirectX.h"

int CALLBACK WinMain(
	HINSTANCE hInstance,
	HINSTANCE hPrevInstance,
	LPSTR lpCmdLine,
	int  nCmdShow)
{
	SoosEngineDirectX soos;

	soos.Initialize();
	soos.Run();
	soos.Shutdown();

	return 0;
}

Potrudićemo se da baš ovako i bude – veoma malo koda, veoma jednostavno za čitanje, sa jasnim specijalizacijama (za sistem i grafički sistem). Fokusiramo se za početak na Windows i DirectX, no nikako ne isključujemo Linux niti OpenGL! Samo ćemo im prići tek jednom kad završimo ovu vizitu. Hajde da zavirimo u budućnost i vidimo na šta će ovaj projekat ličiti na kraju ove nedelje (klik na sliku za bolji prikaz):

Krenućemo sa Core paketom. Da bi sve bilo kako treba, doduše, moramo uraditi malo više od finog crteža: za početak, ISoosEngine.h mora postojati. To bi bilo ono što bismo odmah radili da hoćemo samo Windows, samo DirectX, no to nam nije ideja, tako da idemo na malo opštiju strukturu.


// ISoosEngine.h
#pragma once

class ISoosEngine
{
public:
	ISoosEngine();
	~ISoosEngine();

	void Initialize();
	void Update();
	void Shutdown();
};

Sledeći korak jeste da obezbedimo da ISoosEngine bude lako naslediv, za šta ćemo koristiti CRTP (Curiously Recurring Template Pattern), idiom u kom generička nadklasa biva nasleđena od strane klase koja je sama svoj deda, tehnički govoreći. Drugim rečima:


template<class T>
class Base {};

class Derived : public Base {};

Što ovako, možda se pitate? S jedne strane, zato što je od dva do šest puta brže od dinamičkih virtualnih metoda. Sa druge, zato što ovaj metod, poznatiji pod imenom static method dispatch, stvara određene garancije za vreme kompajliranja, a to je uvek dobro imati u programiranju na niskom nivou. U svakom slučaju, da vidimo kako to sve izgleda kod nas:


// Helpers.h
#define DERIVED static_cast<T*>(this)

(ovo radimo samo da bismo se pomogli kasnije, u sledećem delu koda)

// ISoosEngine.h
#pragma once

#include "Helpers.h”

template<typename T, typename Resources>
class ISoosEngine
{
public:	
	ISoosEngine() {}
	~ISoosEngine() {}

	void Initialize();
	void Update();
	void Shutdown();

	template<typename T>
	T* GetAspect()
	{
		return static_cast<T*>
			(&this->resourceManager);
	}

protected:
	Resources resourceManager;
};

template<typename T, typename Resources>
void ISoosEngine<T, Resources>::Initialize()
{
	DERIVED->Initialize();
}

template<typename T, typename Resources>
void ISoosEngine<T, Resources>::Update()
{
	DERIVED->Update(delta);
}

template<typename T, typename Resources>
void ISoosEngine<T, Resources>::Shutdown()
{
	DERIVED->Shutdown();
}

Zasad, imamo šta nam treba. U nadklasi (Base u primeru gore) je definisano tri metode i njihove implementacije pozivaju metode iz klasa ispod njih (Derived u primeru). Zbog ovog je sad moguće naslediti ovu klasu (tehnički, interfejs) i srediti je tako da radi ono što mi hoćemo. Doduše, prvo malo šećerenja:

// častim onoga ko mi pošalje mail i kaže mi da ne radim ovakve stvari
#define SOOS_ENGINE_DEFINITION(className) \
	class className : public ISoosEngine<className,
#define USING(resources) resources>

Sada kada odlučimo da implementiramo ISoosEngine, to radimo ovako:


// SoosEngineDirectX.h
#pragma once

#include "ISoosEngine.h"

SOOS_ENGINE_DEFINITION(SoosEngineDirectX) USING(ResourcesDirectX)
{
public:
	SoosEngineDirectX();
	~SoosEngineDirectX();

	void Initialize();
	void Run();
	void Shutdown();
}; 

Super nam ide – klasa kojom smo počeli, SoosEngineDirectX, je nastala. Sad još da je ispunimo tako da nam da prozor koji nam treba. Crni prozor, za početak.

U ovom trenutku ću dozvoliti sebi da odlutam malo napred. Ispred nas je jedan od teških delova pisanja engine-a, a to je splet stvari koje ga, ustvari, čine. Naizgled odvojenih delova, trebalo bi naglasiti. Imamo gomilu menadžera: menadžer zvuka, slike, ulaznih uređaja, prozora, ponekad čak i nekih drugih stvari. Sve ovo je u nekoj vrsti veze i za neke od njih možemo odmah reći u kakvom će odnosu biti, no u najboljem slučaju možemo biti sigurni da će sve to imati veze jedno sa drugim u nekom trenutku. Da bismo izbegli oba ekstrema (totalnu zbrku gde je sve u jednoj ogromnoj klasi, sa jedne i, sa druge, referencijalni pakao u kom svi imaju parče ličnog neba i niko ne zna kad će nekome drugome njegovo biti potrebno), ići ćemo stazom koja će, na početku, naježiti mnoge Java programere…

Tako je, napravićemo Resources klasu koja će imati gomilu menadžera resursa, i sve ih nasleđivati. Ona će, tehnički, biti svi oni, i moći će da se, u ogledalu, ogleda kao svako od njih ponaosob. Da vidimo kako će to izgledati:


// IResources.h
#pragma once

struct IResourceManager {
	virtual void Initialize() = 0;
	virtual void Update() = 0;
	virtual void Shutdown() = 0;

	template<typename T>
	inline T* As()
	{
		return dynamic_cast<T*>(this);
	}
};

template<typename... ResourceManagers>
class IResources : public ResourceManagers... {};

#define DATA_ONLY_RESOURCE \
	void Initialize() {}; \
	void Update() {}; \
	void Shutdown() {}

Kao što se može primetiti, ovde koristimo virtualne metode: cilj nam je da olakšamo pisanje i korišćenje menadžera, a bilo bi dobro, zbog njihove raznovrsnosti, da oni sami ne budu templejtovani.
Zanimljiv deo koda odozgo je svakako i instanca tzv. variadic template pristupa, naime:


template<typename... ResourceManagers>
class IResources : public ResourceManagers... {};

Ovim smo ukratko rekli kompajleru da će dobiti nekoliko menadžera, i da ih sve nasledi. Da vidimo i ovo u akciji i da napravimo svoje prve menadžere:


// ConfigurationManager.h
#pragma once

#include "IResources.h"

struct ConfigurationManager : public IResourceManager
{
	DATA_ONLY_RESOURCE;

	const bool WindowFullscreen = false;
	const wchar_t* ApplicationName = L"Soos!";
};

Jedan za konfiguraciju, samo sa konstantama koje nam sad trebaju, a drugi za bavljenje prozorima u Windowsu:


// WindowsManagerDirectX.h
#pragma once

#include "IResources.h"

#define WIN32_LEAN_AND_MEAN
#include 

class WindowsManagerDirectX : public IResourceManager
{
public:
	HINSTANCE instance;
	HWND window;

private:
	LRESULT CALLBACK MessageHandler(HWND, UINT, WPARAM, LPARAM);
	static LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);

	static WindowsManagerDirectX* appHandle;

	MSG msg;

	struct
	{
		unsigned int width;
		unsigned int height;
	} screen;

public:
	WindowsManagerDirectX();
	~WindowsManagerDirectX();

	bool closed;
	void Initialize();
	void Update();
	void Shutdown();
};

Malo veća klasa, no sve ovo će nam ubrzo zatrebati da lansiramo svoj prozor. Hajde da ovo prvo uvežemo u naš paket resursa:


// SoosEngineDirectX.h, početak fajla sad izgleda ovako
#pragma once

#include "ISoosEngine.h"
#include "IResources.h"

#include "ConfigurationManager.h"
#include "WindowsManagerDirectX.h"

typedef ConfigurationManager Config;
typedef WindowsManagerDirectX Windows;

class ResourcesDirectX : public IResources<Config, Windows> {};

Klasa ResourcesDirectX je sada oba ova resurs menadžera, no i više – ona ima mogućnost da se promeni iz jednog svog aspekta u drugi, čime nam otvara mnogo jednostavnije načine rada kasnije! Sada ćemo da popunimo klasu WindowsManagerDirectX i da dobijemo svoj prozor.


// WindowsManagerDirectX.cpp

#include "WindowsManagerDirectX.h"

#include "ConfigurationManager.h"
#include "SoosEngineDirectX.h"

#include 

WindowsManagerDirectX* WindowsManagerDirectX::appHandle = nullptr;

WindowsManagerDirectX::WindowsManagerDirectX()
: closed(false) {}

WindowsManagerDirectX::~WindowsManagerDirectX() {}

void WindowsManagerDirectX::Initialize() 
{ … }

Za sve one koji se nikad pre nisu bavili Windows ili DirectX API funkcijama, vreme je za brzu lekciju: sve u ovim API paketima se bazira na deskriptorima kojim prvo opišete šta želite (ugovori, recimo), a onda na pozivima funkcija koje koriste deskriptore kako bi dovele sistem u željeno stanje. Time i počinjemo, a ovaj deo neću posebno objašnjavati jer je objašnjen svuda po netu.


void WindowsManagerDirectX::Initialize()
{
	ZeroMemory(&msg, sizeof(msg));

	WindowsManagerDirectX::appHandle = this;

	this->instance = GetModuleHandle(nullptr);
	const wchar_t* name = this->As<ConfigurationManager>()->ApplicationName;

	WNDCLASSEX windowDescriptor;
	{
		windowDescriptor.cbSize = sizeof(WNDCLASSEX);
		windowDescriptor.style = CS_HREDRAW | CS_VREDRAW | CS_OWNDC;
		windowDescriptor.lpfnWndProc = WindowsManagerDirectX::WndProc;
		windowDescriptor.cbClsExtra = 0;
		windowDescriptor.cbWndExtra = 0;
		windowDescriptor.hInstance = this->instance;
		windowDescriptor.hIcon = LoadIcon(nullptr, IDI_WINLOGO);
		windowDescriptor.hIconSm = windowDescriptor.hIcon;
		windowDescriptor.hCursor = LoadCursor(nullptr, IDC_ARROW);
		windowDescriptor.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH);
		windowDescriptor.lpszMenuName = nullptr;
		windowDescriptor.lpszClassName = name;
	}

	RegisterClassEx(&windowDescriptor);

	...

U ovom momentu, imamo klasu koju registrujemo za svoj prozor, što tehnički znači da imamo i (još uvek veoma maglovit i nikakav) prozor! Pažnju treba obratiti na naznačeni deo reda: svaka Windows forma ima za sebe vezan jedan WndProc (Window Process, valjda), što je funkcija koja odlučuje o onim ključnim stvarima kao što su “šta raditi ako sistem pošalje poruku za gašenje” i slično. Kod nas, tu poruku preuzima naša klasa, svojom statičkom WndProc metodom. Ne mora ovako da se zove, no lakše ju je prepoznati tako. Druga, veoma slična metoda je ona koja se bavi ostalim porukama, kao što su tajmeri, unos sa tastature i miša, iscrtavanje i obnavljanje ekrana, itd., a nju smo definisali kao MessageHandler metodu. Više o njima kasnije, zasad, da se vratimo na prozor i nastavimo tamo gde smo stali:


	...
	unsigned int screenWidth, screenHeight;

	int positionX, positionY;

	if (this->As<ConfigurationManager>()->WindowFullscreen) 
	...

U ovom delu (a i jednom iznad) možemo videti kako pretvaramo naš WindowManager u ConfigurationManager (samo zato što možemo) i iz njega vučemo informaciju o tome da li se aplikacija uključuje u obliku malog prozora ili zauzima ceo ekran. Ovoga ćemo se nagledati još! Sada da vidimo koliki nam je prozor i gde stoji, u zavisnosti od toga koliko ekrana popunjava:


	...

	if (this->As<ConfigurationManager>()->WindowFullscreen)
	{
		screenWidth = GetSystemMetrics(SM_CXSCREEN);
		screenHeight = GetSystemMetrics(SM_CYSCREEN);

		DEVMODE screenSettingsDescriptor;
		{
			memset(&screenSettingsDescriptor, 0, 
				sizeof(screenSettingsDescriptor));
			screenSettingsDescriptor.dmSize = 
				sizeof(screenSettingsDescriptor);
			screenSettingsDescriptor.dmPelsWidth = 
				(unsigned long)screenWidth;
			screenSettingsDescriptor.dmPelsHeight = 
				(unsigned long)screenHeight;
			screenSettingsDescriptor.dmBitsPerPel = 32;
			screenSettingsDescriptor.dmFields = 
				DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT;
		}

		ChangeDisplaySettings(&screenSettingsDescriptor, CDS_FULLSCREEN);
		positionX = positionY = 0;

		ShowCursor(false); // Sve čudne metode dolaze iz windows.h biblioteke :)
	}
	else
	{
		screenWidth = 800;
		screenHeight = 600;

		positionX = (GetSystemMetrics(SM_CXSCREEN) - screenWidth) / 2;
		positionY = (GetSystemMetrics(SM_CYSCREEN) - screenHeight) / 2;
	}

	...

Kada smo završili sa podešavanjem veličine i pozicije prozora, napokon ga i pravimo i prikazujemo na ekran. Iz ovog dela ćemo dobiti jedan od najvažnijih delova Windows menadžmenta, a to je takozvani HWND (H stoji za “Handle”, WND za “Window”), koji je bitan jer se većina drugih Windows funkcija oslanja upravo na njega, kao što se može videti već u narednih nekoliko redova, kada prikazujemo prozor, postavljamo fokus, itd. Kod nas se HWND zove this->window, jer je čitkije od HWND-a, a mi nismo hipsteri.


	...

	this->screen.width = screenWidth;
	this->screen.height = screenHeight;

	this->window = CreateWindowEx(WS_EX_APPWINDOW, name, name,
		WS_CLIPSIBLINGS | WS_CLIPCHILDREN | WS_POPUP | WS_CAPTION,
		positionX, positionY, screenWidth, screenHeight,
		nullptr, nullptr, this->instance, nullptr);

	ShowWindow(this->window, SW_SHOW);
	SetForegroundWindow(this->window);
	SetFocus(this->window);
}

...

To je to! Sad još da popunimo Update i Shutdown metode, i sve ostalo je mačiji kašalj. Update metoda treba da u svakom pokretanju proveri da li ima novih poruka, da ih dispečuje i obradi, a zatim da vidi da li je sistem zahtevao da se ugasimo. Ako jeste, treba da nas dovede do gašenja.


...

void WindowsManagerDirectX::Update()
{
	if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
	{
		TranslateMessage(&msg);
		DispatchMessage(&msg);
	}

	if (msg.message == WM_QUIT)
	{
		this->closed = true;
	}
}

...

Pri gašenju, treba da prikažemo kurzor, izađemo iz neke čudne rezolucije u kojoj smo možda bili, uništimo prozor i polako obrišemo sve resurse koje smo napravili prilikom inicijalizacije. Evo kako to izgleda:


...

void WindowsManagerDirectX::Shutdown()
{
	ShowCursor(true);

	if (this->As<ConfigurationManager>()->WindowFullscreen)
	{
		ChangeDisplaySettings(nullptr, 0);
	}

	DestroyWindow(this->window);
	this->window = nullptr;

	UnregisterClass(this->As<ConfigurationManager>()->ApplicationName, 
		this->instance);
	this->instance = nullptr;
}

...

Gore spomenusmo dve statičke metode koje se bakću porukama iz sistema. Prva od njih je WndProc i ona izgleda ovako:


...

LRESULT CALLBACK WindowsManagerDirectX::WndProc(HWND hwnd, UINT umessage, WPARAM wparam, LPARAM lparam)
{
	switch (umessage)
	{
	case WM_DESTROY:
	case WM_CLOSE:
		PostQuitMessage(0);
		return 0;
	default:
		return WindowsManagerDirectX::appHandle->MessageHandler(
			hwnd, umessage, wparam, lparam);
	}
}

...

Ono što možemo da zaključimo iz nje je da gasi aplikaciju ako to treba da se desi, i da u svim drugim slučajevima prosleđuje akciju MessageHandler metodi našeg menadžera:


...

LRESULT CALLBACK WindowsManagerDirectX::MessageHandler(HWND hWnd, UINT umsg, WPARAM wparam, LPARAM lparam)
{
	switch (umsg)
	{	
	default:
		return DefWindowProc(hWnd, umsg, wparam, lparam);
	}
}

Ok, ovo je sve što se tiče početnih resursa! Sada da to uvežemo nazad u našoj glavnoj klasi:


// SoosEngineDirectX.h
#pragma once

#include "ISoosEngine.h"

SOOS_ENGINE_DEFINITION(SoosEngineDirectX) USING(ResourcesDirectX)
{
public:
	SoosEngineDirectX();
	~SoosEngineDirectX();

	void Initialize();
	void Run();
	void Shutdown();
};

i njenoj implementaciji:


// SoosEngineDirectX.cpp

#include "SoosEngineDirectX.h"

SoosEngineDirectX::SoosEngineDirectX()
: paused(false), stopped(true) {}

SoosEngineDirectX::~SoosEngineDirectX() {}

void SoosEngineDirectX::Initialize()
{
	this->GetAspect<Windows>()->Initialize();
}

void SoosEngineDirectX::Run()
{
	this->stopped = false;

	Windows* const windows = this->GetAspect<Windows>();

	while (!this->stopped)
	{
		windows->Update();
		this->stopped = windows->closed;
	}

	this->stopped = true;
}

void SoosEngineDirectX::Shutdown()
{
	this->GetAspect()->Shutdown();
}

Dame i gospodo, to je to. Ako sada pokrenete ovu aplikaciju, videćete jedan crni prozor ili, ako ste bili hrabri i eksperimentisali, pun ekran crnila!

soos1

Potrebno nam je da napravimo još nekoliko menadžera (sledeći su za tastaturu i crtanje!), no imamo utabanu stazu i nema problema na vidiku! Priznajem, ova prva nedelja nije baš mistična i avanturistička, no ovo je tek prvih nekoliko strana, a već sad imamo postavku koja bi mogla lako da podrži više platformi i više grafičkih pristupa! Takođe, dodavanje stvari smo sveli na dodavanje menadžera, tako da smo veoma skalabilni. Unos sa tastature i miša, sledeće nedelje.

*Pređite na drugi ili treći deo.