Skrivning av en spelmotor från grunden – del 1: Messaging

author
22 minutes, 12 seconds Read

Följande blogginlägg har, om inget annat anges, skrivits av en medlem av Gamasutras community.
De tankar och åsikter som uttrycks är skribentens och inte Gamasutras eller dess moderbolags.

Del 1 – Messaging
Del 2 – Memory
Del 3 – Data & Cache
Del 4 – Graphics Libraries

Vi lever i en fantastisk tid att vara utvecklare. Med en sådan enorm mängd fantastiska AAA-grade Engines tillgängliga för alla kan det vara lika enkelt som att dra och släppa att göra enkla spel. Det verkar inte finnas någon anledning alls att skriva en motor längre nuförtiden. Och med den allmänna uppfattningen ”Skriv spel, inte motorer”, varför skulle du göra det?

Den här artikeln riktar sig i första hand till ensamutvecklare och små team. Jag förutsätter en viss förtrogenhet med objektorienterad programmering.

Jag vill ge dig en inblick i hur man närmar sig motorutveckling och kommer att använda en enkel fiktiv motor för att illustrera detta.

Varför skriva en motor?

Det korta svaret är: Gör det inte, om du kan undvika det.

Livet är för kort för att skriva en motor för varje spel (Taget från boken 3D Graphics Programming av Sergei Savchenko)

Det nuvarande urvalet av utmärkta motorer som Unity, Unreal eller CryEngine är så flexibla som man kan hoppas på och kan användas för att göra i stort sett alla spel. För mer specialiserade uppgifter finns det naturligtvis mer specialiserade lösningar som Adventure Game Studio eller RPG Maker, bara för att nämna några. Inte ens kostnaden för kommersiella Engines är ett argument längre.

Det finns bara några få nischade skäl kvar att skriva en egen motor:

  • Du vill lära dig hur en motor fungerar
  • Du behöver vissa funktioner som inte finns tillgängliga eller de tillgängliga lösningarna är instabila
  • Du tror att du kan göra det bättre/snabbare
  • Du vill behålla kontrollen över utvecklingen

Alla dessa är helt giltiga anledningar och om du läser det här tillhör du antagligen något av dessa läger. Mitt mål är att inte gå in i en långvarig ”Vilken motor ska jag använda?” eller ”Ska jag skriva en motor?”-debatt här och kommer att hoppa rakt in i den. Så låt oss börja.

Hur man misslyckas med att skriva en motor

Vänta. Först säger jag att du inte ska skriva en, sedan förklarar jag hur man misslyckas? Bra introduktion…

Det finns många saker att tänka på innan man ens skriver en enda kodrad. Det första och största problemet som alla som börjar skriva en spelmotor har kan kokas ner till följande:

Jag vill se spelupplevelser så snabbt som möjligt!

Jo snabbare du inser att det kommer att ta mycket tid innan du faktiskt ser något intressant hända, desto bättre kommer du att klara av att skriva din motor.

Tvinga din kod att visa någon form av grafik eller gameplay så fort du kan, bara för att få en visuell bekräftelse på ”framsteg”, är din största fiende vid denna tidpunkt. Ta. Din. Tid!

Det är inte ens aktuellt att börja med grafiken. Du har förmodligen läst många OpenGL/DirectX-handledningar och böcker och vet hur man renderar en enkel triangel eller sprite. Du kanske tror att ett kort kodutdrag för att rendera ett litet nät på skärmen är ett bra ställe att börja. Det är det inte.

Ja, dina första framsteg kommer att vara fantastiska. Du skulle kunna springa runt på en liten nivå i First-Person på bara en dag genom att kopiera och klistra in kodstycken från olika handledningar och Stack Overflow. Men jag garanterar dig att du kommer att radera varenda rad av den koden två dagar senare. Ännu värre är att du kanske till och med blir avskräckt från att skriva en Engine, eftersom det inte är motiverande att se mindre och mindre.

Det andra stora problemet som utvecklare möter när de skriver Engines är feature creep. Alla vill gärna skriva Engines heliga graal. Alla vill ha den perfekta motorn som kan göra allt. First Person Shooters, taktiska rollspel och så vidare. Men faktum är att vi inte kan göra det. Ännu. Se bara på de stora namnen. Inte ens Unity kan verkligen tillgodose varje spelgenre perfekt.

Tänk inte ens på att skriva en motor som kan göra mer än en genre på första försöket. Gör det inte!

Var ska man egentligen börja när man skriver en Engine

Att skriva en Engine är som att konstruera en riktig motor till en bil. Stegen är faktiskt ganska uppenbara, förutsatt att du vet vilket spel (eller vilken bil) du arbetar med. Här är de:

  1. Pinpoint exakt vad din motor behöver kunna OCH vad din motor inte behöver kunna.
  2. Organisera behoven i system som din motor kommer att kräva.
  3. Utforma din perfekta arkitektur som binder samman alla dessa system.
  4. Upprepa steg 1-3 så ofta som möjligt.
  5. Koda.

Om (= om och endast om) du lägger tillräckligt med tid och ansträngning på steg 1-4 och spelets utformning inte plötsligt förändras från ett skräckspel till en spelautomat (läs: Silent Hill), kommer kodningen att bli ett mycket trevligt företag. Kodning kommer fortfarande att vara långt ifrån lätt, men fullt hanterbart, även för ensamutvecklare.

Detta är anledningen till att den här artikeln huvudsakligen handlar om steg 1-4. Tänk på steg 5 som ”att fylla i tomrummen”. 50.000 LOC av tomrum”.

Den mest avgörande delen av allt detta är steg 3. Vi kommer att koncentrera oss på detta!

Steg 1. Fastställ behoven och behoven inte

Alla dessa steg kan verka ganska triviala vid första anblicken. Men det är de verkligen inte. Du kanske tror att steg 1 i processen för att utveckla en motor för First Person Shooter kan kokas ner till följande:

Jag måste ladda in en nivå, spelarens pistol, några fiender med AI. Klart, vidare till steg 2.

Om det bara vore så enkelt. Det bästa sättet att genomföra steg 1 är att gå igenom hela spelet klick för klick, åtgärd för åtgärd från det att du klickar på ikonen på skrivbordet till det att du trycker på Exit-knappen efter att ha rullat krediterna. Gör en lista, en stor lista över vad du behöver. Gör en lista över vad du definitivt inte behöver.

Detta kommer förmodligen att gå till så här:

Jag startar spelet och det går direkt till huvudmenyn. Kommer menyn att använda en statisk bild? En avklippt scen? Hur styr jag huvudmenyn med musen? Tangentbord? Vilken typ av GUI-element behöver jag för huvudmenyn? Knappar, formulär, rullningslister? Hur är det med musik?

Och det är bara makroöverväganden. Gå in så detaljerat som möjligt. Att bestämma sig för att du behöver knappar är bra, men fundera också på vad en knapp kan göra.

Jag vill att knapparna ska ha fyra tillstånd: Upp, Hover, Ned, Inaktiverad. Kommer jag att behöva ljud för knapparna? Hur är det med specialeffekter? Är de animerade när de är inaktiva?

Om din lista över behov och behov som inte är nödvändiga endast innehåller cirka 10 objekt i slutet av huvudmenyn har du gjort något fel.

I det här skedet simulerar du motorn i din hjärna och skriver ner vad som behöver göras. Steg 1 kommer att bli tydligare för varje upprepning, oroa dig inte för att missa något första gången.

Steg 2. Organisera behoven i system

Så har du dina listor över saker som du behöver och inte behöver. Det är dags att organisera dem. Uppenbarligen kommer GUI-relaterade saker som knappar att hamna i något slags GUI-system. Renderingrelaterade saker hamnar i Graphics System / Engine.

Som i steg 1 kommer det att bli mer uppenbart att bestämma vad som skall vara var vid din andra iteration, efter steg 3. För den första omgången grupperar du dem logiskt som i exemplet ovan.

Den bästa referensen om ”vad som ska gå vart” och ”vad som gör vad” är utan tvekan boken Game Engine Architecture av Jason Gregory.

Börja med att gruppera funktionerna. Börja fundera på sätt att kombinera dem. Du behöver inte Camera->rotateYaw(float yaw) och Camera->rotatePitch(float pitch) om du kan kombinera dem till Camera->rotate(float yaw, float pitch). Håll det enkelt. För mycket funktionalitet (kom ihåg, feature creep) kommer att skada dig senare.

Tänk på vilken funktionalitet som behöver exponeras offentligt och vilken funktionalitet som bara behöver finnas i själva systemet. Till exempel behöver din Renderer sortera alla transparenta Sprites innan du ritar. Funktionen för att sortera dessa sprites behöver dock inte exponeras. Du vet att du behöver sortera transparenta sprites innan du ritar, du behöver inte något externt system som talar om detta för dig.

Steg 3. Arkitekturen (eller själva artikeln)

Vi kunde lika gärna ha börjat artikeln här. Detta är den intressanta och viktiga delen.

En av de enklaste möjliga arkitekturerna som din motor kan ha är att placera varje system i en klass och låta Main Game Loop kalla deras underrutiner. Det kan se ut ungefär så här:

while(isRunning)
{
Input->readInput();
isRunning = GameLogic->doLogic();
Camera->update();
World->update();
GUI->update();
AI->update();
Audio->play();
Render->draw();
}

Det verkar först helt rimligt. Du har alla grunderna täckta, Input -> bearbetning av Input -> Output.

Och det räcker faktiskt för ett enkelt spel. Men det kommer att vara en plåga att underhålla. Orsaken till detta borde vara uppenbar: beroenden.

Varje system måste kommunicera med andra system på något sätt. Vi har inget sätt att göra det i vår spelslinga ovan. Därför visar exemplet tydligt att varje system måste ha någon referens till de andra systemen för att kunna göra något meningsfullt. Vårt grafiska gränssnitt och vår spellogik måste veta något om vår inmatning. Vår Renderer måste veta något om vår spellogik för att kunna visa något meningsfullt.

Detta leder till detta arkitektoniska underverk:

Om det luktar spaghetti så är det spaghetti. Definitivt inte vad vi vill ha. Ja, det är enkelt och snabbt att koda. Ja, vi kommer att få acceptabla resultat. Men underhållbart är det inte. Om man ändrar en liten bit kod någonstans kan det få förödande effekter på alla andra system utan att vi vet om det.

Det kommer dessutom alltid att finnas kod som många system behöver ha tillgång till. Både GUI och Renderer måste göra Draw calls eller åtminstone ha tillgång till något slags gränssnitt för att hantera detta åt oss. Ja, vi skulle kunna ge varje system möjlighet att anropa OpenGL/DirectX-funktioner direkt, men vi kommer att få många överflödiga funktioner.

Vi skulle kunna lösa detta genom att samla alla ritfunktioner i Renderersystemet och anropa dem från GUI-systemet. Men då kommer Rendering System att ha specifika funktioner för GUI. Dessa har ingen plats i Renderersystemet och strider därför mot steg 1 och 2. Beslut, beslut.

Det första vi bör överväga är alltså att dela upp vår motor i lager.

Engine Lasagne

Lasagne är bättre än spaghetti. Åtminstone programmeringsmässigt. Om vi håller oss till vårt Renderer-exempel vill vi anropa OpenGL/DirectX-funktioner utan att anropa dem direkt i systemet. Detta luktar som en Wrapper. Och för det mesta är det det också. Vi samlar all ritfunktionalitet i en annan klass. Dessa klasser är ännu enklare än våra system. Låt oss kalla dessa nya klasser för Framework.

Tanken bakom detta är att abstrahera bort en hel del API-anrop på låg nivå och forma dem till något som är skräddarsytt för vårt spel. Vi vill inte ställa in Vertex Buffer, ställa in Index Buffer, ställa in Textures, aktivera det här, inaktivera det där bara för att göra ett enkelt ritningsanrop i vårt Renderer System. Låt oss lägga allt detta på låg nivå i vårt ramverk. Jag kallar den här delen av ramverket för ”Draw”. Varför? Allt den gör är att ställa in allting för att rita och sedan rita det. Den bryr sig inte om vad den ritar, var den ritar, varför den ritar. Det lämnas till Renderer System.

Detta kan tyckas vara en konstig sak, vi vill ha snabbhet i vår motor, eller hur? Fler abstraktionslager = mindre hastighet.

Och du skulle ha rätt, om det var 90-talet. Men vi behöver underhållbarheten och kan leva med den knappt märkbara hastighetsförlusten för de flesta delar.

Hur ska då vårt Draw Framework utformas? Enkelt uttryckt, som vårt eget lilla API. SFML är ett bra exempel på detta.

Viktiga saker att tänka på:

  • Håll det väldokumenterat. Vilka funktioner har vi? När kan de anropas? Hur anropas de?
  • Håll det enkelt. Enkla funktioner som drawMesh(Mesh* oMesh) eller loadShader(String sPath) kommer att göra dig nöjd i det långa loppet.
  • Håll det funktionellt. Var inte för specifik. istället för drawButtonSprite, ha en drawSprite funktion och låt anroparen sköta resten.

Vad vinner vi? Mycket:

  • Vi behöver bara ställa in vårt ramverk en gång och kan använda det i varje system vi behöver (GUI, Renderer….)
  • Vi kan enkelt ändra de underliggande API:erna om vi vill, utan att skriva om varje system. Byt från OpenGL till DirectX? Inga problem, skriv bara om ramklassen.
  • Det håller koden i våra system ren och tät.
  • Att ha ett väldokumenterat gränssnitt innebär att en person kan arbeta med ramklassen medan en person arbetar i systemskiktet.

Vi kommer förmodligen att sluta med något som liknar det här:

Min tumregel för vad som ska ingå i ramklassen är ganska enkel. Om jag behöver anropa ett externt bibliotek (OpenGL, OpenAL, SFML…) eller har datastrukturer/algoritmer som varje system behöver, bör jag göra det i ramverket.

Vi har nu vårt första lager av lasagne färdigt. Men vi har fortfarande denna enorma boll av spaghetti ovanför den. Låt oss ta itu med det härnäst.

Messaging

Det stora problemet kvarstår dock. Våra system är fortfarande alla sammankopplade. Det vill vi inte ha. Det finns en mängd olika sätt att hantera detta problem. Händelser, meddelanden, abstrakta klasser med funktionspekare (så esoteriskt)…

Låt oss hålla oss till meddelanden. Detta är ett enkelt koncept som fortfarande är mycket populärt inom GUI-programmering. Det är också väl lämpat som ett enkelt exempel för vår motor.

Det fungerar som ett postverk. Företag A skickar ett meddelande till företag B och begär att något ska göras. Dessa företag behöver ingen fysisk förbindelse. Företag A utgår helt enkelt från att företag B kommer att göra det någon gång. Men för tillfället bryr sig företag A egentligen inte om när eller hur företag B gör det. Det behöver bara göras. Företag B kanske till och med bestämmer sig för att omdirigera meddelandet till företag C och D och låta dem sköta det.

Vi kan gå ett steg längre, företag A behöver inte ens skicka det till någon specifik person. Företag A lägger helt enkelt ut brevet och den som känner sig ansvarig kommer att behandla det. På så sätt kan företag C och D direkt behandla begäran.

Oppenbart är att företagen motsvarar våra system. Låt oss ta en titt på ett enkelt exempel:

  1. Framework meddelar inmatningssystemet att ”A” trycktes ner
  2. Input översätter att tangenttryckning ”A” betyder ”Öppna inventarier” och skickar ett meddelande som innehåller ”Öppna inventarier”
  3. GUI hanterar meddelandet och öppnar inventeringsfönstret
  4. Game Logic hanterar meddelandet och pausar spelet

Input bryr sig inte ens om vad som görs med dess meddelande. GUI bryr sig inte om att Game Logic också behandlar samma meddelande. Om de alla var kopplade skulle Input behöva anropa en funktion i GUI-systemet och en funktion i spellogiken. Men det behövs inte längre. Vi kunde framgångsrikt frikoppla detta med hjälp av Messages.

Hur ser ett Message ut? Det bör åtminstone ha någon typ. Till exempel kan öppnandet av inventariet vara något enum som heter OPEN_INVENTORY. Detta räcker för enkla meddelanden som detta. Mer avancerade Messages som måste innehålla data behöver något sätt att lagra dessa data. Det finns en mängd olika sätt att åstadkomma detta. Det enklaste är att använda en enkel mappstruktur.

Men hur skickar vi meddelanden? Via en Message Bus förstås!

Är det inte vackert? Ingen mer spaghetti, bara god gammal vanlig lasagne. Jag placerade medvetet vår spellogik på andra sidan av meddelandebussen. Som du kan se har den ingen anslutning till ramlagret. Detta är viktigt för att undvika frestelsen att ”bara anropa den där funktionen”. Lita på mig, du kommer att vilja göra det förr eller senare, men det skulle förstöra vår design. Vi har tillräckligt med system som har att göra med ramverket, vi behöver inte göra det i vår spellogik.

Messagebussen är en enkel klass med referenser till varje system. Om den har ett meddelande i kö, skickar Message Bus det till varje system via ett enkelt handleMessage(Msg msg) anrop. I gengäld har varje system en referens till meddelandebussen för att kunna skicka meddelanden. Denna kan naturligtvis lagras internt eller skickas in som ett funktionsargument.

Alla våra System måste därför ärva eller vara av följande form:

class System
{
public:
void handleMessage(Msg *msg);
{
switch(msg->type)
{
//// Example
//case Msg::OPEN_INVENTORY:
// break;
}
}

private:

MessageBus *msgBus;
//// Usage: msgBus->postMessage(msg);
}

(Ja, ja, råa pekare…)

På ett ögonblick förändras vår Spelslinga till att helt enkelt låta Meddelandebussen sända runt meddelanden. Vi kommer fortfarande att behöva uppdatera varje system regelbundet via någon form av update() anrop. Men kommunikationen kommer att hanteras på ett annat sätt.

När vi använder Messages, precis som med våra ramverk, skapas dock overhead. Detta kommer att sakta ner motorn lite, låt oss inte lura oss själva. Men vi bryr oss inte! Vi vill ha en ren och enkel design. En ren och enkel arkitektur!

Och den coolaste delen? Vi får fantastiska saker gratis!

Konsolen

Varje meddelande är i stort sett ett funktionsanrop. Och varje meddelande skickas i stort sett överallt! Vad händer om vi har ett system som helt enkelt skriver ut varje meddelande som skickas till ett utdatafönster? Tänk om detta system också kan skicka meddelanden som vi skriver in i det fönstret?

Ja, vi har just fött en konsol. Och allt det tog oss är några få rader kod. Jag blev helt förstummad redan när jag för första gången såg det här i praktiken. Den är inte ens knuten till något, den bara existerar.

En konsol är uppenbarligen mycket hjälpsam när vi utvecklar spelet och vi kan helt enkelt ta bort den i Release, om vi inte vill att spelaren ska ha den typen av tillgång.

In-Game Cinematics, Replays & Debugging

Hur blir det om vi förfalskar meddelanden? Vad händer om vi skapar ett nytt system som helt enkelt skickar meddelanden vid en viss tidpunkt? Tänk dig att det skickar något som MOVE_CAMERA, följt av ROTATE_OBJECT.

Och Voila, vi har filmsekvenser i spelet.

Hur vore det om vi helt enkelt spelade in de inmatningsmeddelanden som skickades under spelets gång och sparade dem i en fil?

Och Voila, vi har återspelningar.

Vad händer om vi bara spelar in allt som spelaren gör och när spelet kraschar låter dem skicka dessa datafiler till oss?

Och Voila, vi har en exakt kopia av spelarens handlingar som ledde till kraschen.

Multithreading

Multithreading? Ja, multitrådning. Vi har frikopplat alla våra system. Detta innebär att de kan behandla sina meddelanden när de vill, hur de vill och viktigast av allt, var de vill. Vi kan låta vår meddelandebuss bestämma på vilken tråd varje system ska behandla ett meddelande -> Multi-Threading

Frame Rate Fixing

Vi har för många meddelanden att behandla den här ramen? Inga problem, vi behåller dem i Message Bus Queue och skickar dem i nästa ram. Detta ger oss möjlighet att se till att vårt spel körs med en jämn 60 FPS. Spelarna kommer inte att märka att AI:n tar några ramar längre tid på sig för att ”tänka”. De kommer dock att märka att bildfrekvensen sjunker.

Meddelanden är coola.

Det är viktigt att vi noggrant dokumenterar varje meddelande och dess parametrar. Behandla det som ett API. Om du gör detta rätt kan varje utvecklare arbeta med olika system utan att bryta något. Även om ett system skulle vara offline eller under uppbyggnad kommer spelet fortfarande att köras och kan testas. Inget ljudsystem? Det är okej, vi har fortfarande visuellt material. Ingen Renderer, det går bra, vi kan använda konsolen…

Men meddelanden är inte perfekta. Tyvärr.

Ibland vill vi veta resultatet av ett meddelande. Ibland vill vi att de ska behandlas omedelbart. Vi måste hitta genomförbara alternativ. En lösning på detta är att ha en Speedway. Förutom en enkel postMessage funktion kan vi implementera en postImmediateMessage funktion som behandlas omedelbart. Det är mycket enklare att hantera returmeddelanden. Dessa skickas till vår handleMessage funktion förr eller senare. Vi behöver bara komma ihåg detta när vi skickar ett meddelande.

Omedelbara meddelanden bryter uppenbarligen mot Multi-Threading och Frame Rate Fixing om de görs i överflöd. Det är därför viktigt att begränsa sig själv för att begränsa deras användning.

Men det största problemet med detta system är latenstiden. Det är inte den snabbaste arkitekturen. Om du arbetar med en First Person Shooter med twitch-liknande svarstider kan detta vara ett problem.

Tillbaka till utformningen av vår arkitektur

Vi har beslutat att använda system och en Message Bus. Vi vet exakt hur vi vill strukturera vår motor.

Det är dags för steg 4 i vår designprocess. Iteration. Vissa funktioner kanske inte passar in i något System, vi måste hitta en lösning. Vissa funktioner måste anropas flitigt och skulle täppa till Message Bus, vi måste hitta en lösning.

Detta tar tid. Men det är värt det i det långa loppet.

Det är äntligen dags att koda!

Steg 4. Var ska man börja koda?

För att börja koda bör du läsa boken/artiklarna Game Programming Patterns av Robert Nystrom.

I övrigt har jag skissat upp en liten färdplan som du kan följa. Det är långt ifrån det bästa sättet, men det är produktivt.

  1. Om du väljer en motor av typen Message Bus, tänk på att koda konsolen och Message Bus först. När dessa är implementerade kan du fejka existensen av alla system som ännu inte har kodats. Du kommer att ha konstant kontroll över hela motorn i varje utvecklingsstadium.
  2. Konsultera att gå vidare till det grafiska gränssnittet härnäst, liksom den nödvändiga funktionaliteten för ritning inom ramverket. Ett solidt GUI tillsammans med konsolen kommer att göra det möjligt för dig att fejka alla andra system ännu enklare. Testning kommer att bli en lätt sak.
  3. Nästa bör vara ramverket, åtminstone dess gränssnitt. Funktionaliteten kan följa senare.
  4. Slutligen bör du gå vidare till de andra systemen, inklusive Gameplay.

Du kommer att märka att det sista du gör kan vara att faktiskt rendera något som har med Gameplay att göra. Och det är en bra sak! Det kommer att kännas så mycket mer givande och hålla dig motiverad att slutföra de sista detaljerna i din motor.

Din speldesigner kan dock skjuta dig under denna process. Att testa gameplay genom konsolkommandon är ungefär lika roligt som att spela Counter Strike via IRC.

Slutsats

Ta dig tid att hitta en solid arkitektur och håll dig till den! Det är det råd jag hoppas att du tar med dig från den här artikeln. Om du gör detta kommer du att kunna konstruera en helt fin och underhållbar motor i slutet av dagen. Eller sekel.

Personligen tycker jag mer om att skriva Engines än att göra allt det där med Gameplay. Om du har några frågor är du välkommen att kontakta mig via Twitter @Spellwrath. Jag håller för närvarande på att färdigställa en annan Engine med hjälp av de metoder som jag har beskrivit i den här artikeln.

Du kan hitta del 2 här.

Similar Posts

Lämna ett svar

Din e-postadress kommer inte publiceras.