Vad är dataorienterad spelmotordesign?

Du kanske har hört talas om datainformat spelmotordesign, ett relativt nytt koncept som föreslår en annan inställning till den mer traditionella objektorienterade designen. I den här artikeln ska jag förklara vad DOD handlar om, och varför några spelutvecklare tycker att det kan vara biljetten för spektakulära prestationsvinster.

En bit av historia

Under de första åren av spelutveckling skrevs spel och deras motorer på skola, som C. De var en nischprodukt och klämde varje sista klockcykel ur långsam hårdvara vid den tiden var yttersta prioritet. I de flesta fall var det bara ett blygsamt antal personer som hackade på koden för en enda titel, och de kände till hela kodbasen av hjärtat. Verktygen som de använde hade tjänat dem bra och C gav prestandafördelar som gjorde det möjligt för dem att få ut det mesta av CPU-enheten, och eftersom dessa spel fortfarande var storbundna av CPU: n, dra till sina egna rambuffertar, Detta var en mycket viktig punkt.

Med tillkomsten av GPUer som gör det antal krusande arbetet på trianglarna, texlarna, pixlarna och så vidare, har vi kommit att bero mindre på CPU: n. Samtidigt har spelbranschen sett stabil tillväxt: fler och fler människor vill spela fler och fler spel, vilket i sin tur lett till att fler och fler lag kommer samman för att utveckla dem. 

Moores lag visar att maskinvarustillväxten är exponentiell, inte linjär i förhållande till tiden: det betyder att varje antal år, antalet transistorer som vi kan passa på ett enda kort ändras inte med en konstant mängd - det dubblerar!

Större lag behövde bättre samarbete. Innan länge krävde spelmotorerna med sin komplexa nivå, AI, culling och rendering logik kodarna att vara mer disciplinerad och deras vapen valde var objektorienterad design.

Som Paul Graham sa en gång: 

Vid stora företag tenderar mjukvaran att skrivas av stora (och ofta byta) lag av mediokerprogrammerare. Objektorienterad programmering innebär disciplin på dessa programmerare som hindrar någon av dem från att göra för mycket skada.

Oavsett om vi gillar det eller inte, måste det vara sant att en del större företag började distribuera större och bättre spel, och när standardiseringen av verktyg uppstod blev de hackare som spelade på spel delar som kunde bytas ut lättare. Dikten av en viss hacker blev mindre och mindre viktig.

Problem med objektorienterad design

Medan objektorienterad design är ett bra koncept som hjälper utvecklare till stora projekt, till exempel spel, skapar flera lager av abstraktion och alla arbetar på sitt mållager utan att behöva bry sig om detaljerna för genomförandet av dem nedan, är det bunden att ge oss några huvudvärk.

Vi ser en explosion av parallella programmerings-kodare som skördar alla processorkärnor som är tillgängliga för att leverera brinnande beräkningshastigheter, men samtidigt blir spel landskapet alltmer komplicerat, och om vi vill fortsätta med den trenden och levererar fortfarande ramarna -På andra sekunder våra spelare förväntar sig, vi måste göra det också. Genom att använda all hastighet vi har till hands kan vi öppna dörrar för helt nya möjligheter: Använda CPU-tiden för att minska antalet data som skickas till GPU-enheten, till exempel.

I objektorienterad programmering behåller du tillstånd inom ett objekt, vilket kräver att du introducerar koncept som synkroniserings primitiva om du vill arbeta med det från flera trådar. Du har en ny nivå av indirection för varje virtuellt funktionssamtal du gör. Och minnesåtkomstmönstren som genereras av kod skrivna på ett objektorienterat sätt kan vara hemskt-i själva verket har Mike Acton (Insomniac Games, ex-Rockstar Games) en bra uppsättning bilder som förklarar ett exempel. 

På samma sätt sätter Robert Harper, en professor vid Carnegie Mellon University, det så här: 

Objektorienterad programmering är [...] både antimodulär och parallell av sin natur, och därmed olämplig för en modern CS-kursplan.

Att prata om OOP så här är knepigt, eftersom OOP omfattar ett stort spektrum av egenskaper, och inte alla är överens om vad OOP betyder. I detta avseende talar jag mest om OOP som implementeras av C ++, för det är för närvarande det språk som dominerar spelmotorvärlden.

Så vi vet att spel måste bli parallella eftersom det finns alltid mer arbete som CPU kan (men behöver inte) göra, och spendera cykler väntar på GPU att slutföra bearbetningen är bara slösigt. Vi vet också att vanliga OO-designmetoder kräver att vi introducerar dyra låsebemyndiganden och kan samtidigt bryta cacheplatsen eller orsaka onödig förgrening (vilket kan vara dyrt!) Under de mest oväntade omständigheterna.

Om vi ​​inte utnyttjar flera kärnor, fortsätter vi att använda samma mängd CPU-resurser även om hårdvaran blir godtyckligt bättre (har mer kärnor). Samtidigt kan vi driva GPU till dess gränser eftersom det är, genom design, parallellt och kan ta på sig något arbete samtidigt. Detta kan störa vårt uppdrag att ge spelarna den bästa upplevelsen på hårdvaran, eftersom vi helt klart inte använder den till full potential.

Detta ställer frågan: bör vi ompröva våra paradigmer helt och hållet?

Ange: Dataorienterad design

Vissa förespråkare av denna metod har kallad Det är dataorienterad design, men sanningen är att det allmänna konceptet har varit känt under mycket längre tid. Dess grundläggande förutsättning är enkel: konstruera din kod kring datastrukturerna och beskriv vad du vill uppnå när det gäller manipuleringar av dessa strukturer

Vi har hört den här typen av prats tidigare: Linus Torvalds, skaparen av Linux och Git, sa i en Git-postlista att han är en stor förespråkare för att "utforma koden kring data, inte tvärtom" och krediterar detta som en av anledningarna till Gits framgång. Han fortsätter jämnt för att hävda att skillnaden mellan en bra programmerare och en dålig är om hon oroar sig för datastrukturer, eller själva koden.

Uppgiften kan tyckas vara kontraintuitiv först, eftersom det kräver att du vänder din mentalmodell upp och ner. Men tänk på det så här: ett spel, medan det körs, fångar all användarens inmatning och alla prestanda-tunga bitar av den (de där det skulle vara vettigt att dölja standarden allt är ett föremål filosofi) litar inte på externa faktorer, till exempel nätverk eller IPC. För allt du vet, förbrukar ett spel användarhändelser (mus flyttad, tryck på joystickknappen osv.) Och det aktuella speltillståndet, och churns dessa upp i en ny uppsättning data, till exempel satser som skickas till GPU, PCM-prov som skickas till ljudkortet och ett nytt spel tillstånd.

Denna "data churning" kan delas upp i mycket mer delprocesser. Ett animationssystem tar nästa keyframe-data och det aktuella tillståndet och producerar ett nytt tillstånd. Ett partikelsystem tar sitt nuvarande tillstånd (partikelpositioner, hastigheter osv.) Och en tidsutveckling och producerar ett nytt tillstånd. En släckningsalgoritm tar en uppsättning kandidatreproducerbara och producerar en mindre uppsättning av renderbarheter. Nästan allt i en spelmotor kan ses som att manipulera en bit av data för att producera ytterligare en bit data.

Processorer älskar lokalisering av referens och utnyttjande av cacheminne. Så, i datainriktad design tenderar vi, så långt som möjligt, att organisera allt i stora, homogena arrays och, där det är möjligt, springa bra, cachekonsekventa brute force algoritmer istället för en potentiellt snyggare (som har en bättre Big O-kostnad, men misslyckas med att omfamna arkitekturen begränsningar av hårdvara det fungerar på). 

När det utförs per ram (eller flera gånger per ram) ger detta potentiellt stora prestationsfördelar. Exempelvis rapporterar folket på Scalyr att söka loggfiler vid 20 GB / sek med hjälp av en noggrant utformad men en naiv ljuddämpande brute-force linear scan. 

När vi behandlar objekt måste vi tänka på dem som "svarta lådor" och kalla deras metoder, som i sin tur öppnar uppgifterna och får oss vad vi vill ha (eller gör förändringar som vi vill ha). Detta är bra för att arbeta för underhåll, men det är inte skadligt för prestanda att veta hur våra data läggs ut.

exempel

Dataorienterad design har oss att tänka på data, så låt oss göra något också lite annorlunda än vad vi brukar göra. Tänk på den här koden:

void MyEngine :: queueRenderables () for (auto det = mRenderables.begin (); it! = mRenderables.end (); ++ det) if ((* it) -> isVisible ()) queueRenderable ); 

Även om förenklade mycket, är detta vanliga mönster vad som ofta ses i objektorienterade spelmotorer. Men vänta - om en hel del prestanda inte är synliga, löper vi in ​​många förgreningsföreteelser som orsakar att processorn slänger några instruktioner som den hade utfört i hopp om att en viss filial togs. 

För små scener är det uppenbarligen inte ett problem. Men hur många gånger gör du denna speciella sak, inte bara när du köper renderbarheter, men när det löser sig genom scenljus, splittrar skuggkarta, zoner eller liknande? Vad sägs om AI eller animationsuppdateringar? Multiplicera allt som du gör genom hela scenen, se hur många klockcykler du utvisar, beräkna hur mycket tid din processor har tillgång till för att leverera alla GPU-satser för en stadig 120FPS-rytm och du ser att dessa saker kan skala till en stor mängd. 

Det skulle vara roligt om en hackare som arbetar med en webbapplikation även betraktade sådana minima mikrooptimeringar, men vi vet att spel är realtidssystem där resursbegränsningar är otroligt täta, så det här övervägandet är inte felplacerat för oss.

För att undvika att detta händer, låt oss tänka på det på ett annat sätt: Vad händer om vi höll listan över synliga renderbara i motorn? Visst, vi skulle offra den snygga syntaxen av myRenerable-> dölj () och bryter mot en hel del OOP-principer, men vi kan då göra det här:

void MyEngine :: queueRenderables () for (auto it = mVisibleRenderables.begin (); det! = mVisibleRenderables.end (); ++ det) queueRenderable (* it); 

Hurra! Ingen grenförutsägelser, och antar mVisibleRenderables är trevligt std :: vektor (vilket är en sammanhängande uppsättning), kunde vi ha skrivit om detta så snabbt memcpy ring (med några extra uppdateringar till våra datastrukturer, förmodligen).

Nu kan du ringa mig på den härliga cheesinessen av dessa kodprover och du kommer helt rätt: det här förenklas mycket. Men för att vara ärlig har jag inte ens repat ytan ännu. Att tänka på datastrukturer och deras relationer öppnar oss för en hel del möjligheter som vi inte har tänkt på tidigare. Låt oss titta på några av dem nästa.

Parallellisering och vektorisering

Om vi ​​har enkla, väldefinierade funktioner som fungerar på stora datablock som basbyggstenar för vår bearbetning, är det enkelt att spawna fyra, åtta eller 16 arbetstrådar och ge var och en en bit data för att hålla hela CPU: n kärnor upptagna. Inga mutexer, atom- eller låssatsning, och när du behöver data behöver du bara gå med på alla trådar och vänta på att de ska slutföra. Om du behöver sortera data parallellt (en mycket frekvent uppgift när du förbereder saker som ska skickas till GPU), måste du tänka på detta från ett annat perspektiv - dessa bilder kan hjälpa.

Som en extra bonus kan du använda SIMD-vektorinstruktioner (som SSE / SSE2 / SSE3) i en tråd, för att uppnå en extra hastighetsökning. Ibland kan du bara åstadkomma detta genom att lägga data på ett annat sätt, till exempel att placera vektorkällor i ett struktursystem (SoA) XXX ... YYY ... ZZZ ... ) snarare än den konventionella array-of-strukturerna (AoS; det skulle vara XYZXYZXYZ ... ). Jag klarar knappt på ytan här; Du kan hitta mer information i Vidare läsning avsnitt nedan.

När våra algoritmer hanterar data direkt blir det trivialt att parallellisera dem, och vi kan också undvika vissa hastighetsnacker.

Enhetsprovning du visste inte var möjlig

Att ha enkla funktioner utan externa effekter gör dem enkla att prova på enhet. Detta kan vara särskilt bra i en form av regressionstestning för algoritmer du vill byta in och ut enkelt. 

Till exempel kan du bygga en testpaket för en culling-algoritms beteende, ställa in en orkestrerad miljö och mäta exakt hur det fungerar. När du utarbetar en ny culling-algoritm kör du samma test igen utan några ändringar. Du mäter prestanda och korrekthet, så du kan få bedömning till hands. 

När du får mer in i de datainriktade designmetoderna, hittar du det enklare och enklare att testa aspekter på din spelmotor.

Kombinera klasser och objekt med monolitiska data

Dataorienterad design är inte på något sätt motsats till objektorienterad programmering, bara några av dess idéer. Som ett resultat kan du ganska snyggt använda idéer från datainriktad design och får fortfarande de flesta av de abstraktioner och mentala modeller du är van vid. 

Ta en titt, till exempel på arbetet med OGRE version 2.0: Matias Goldberg, masterminden bakom den strävan, valde att lagra data i stora, homogena arrays och ha funktioner som itererar över hela arrays i motsats till att arbeta på bara ett datum , för att påskynda Ogre. Enligt ett riktmärke (vilket han medger är mycket orättvist, men den uppmätta prestandafördelen kan inte vara endast på grund av det) fungerar det nu tre gånger snabbare. Inte bara det-han behöll många av de gamla, välbekanta klassen abstraktionerna, så API var långt ifrån en fullständig omskrivning.

Är det praktiskt?

Det finns mycket bevis på att spelmotorer på detta sätt kan och kommer att utvecklas.

Molecule Engine-utvecklingsbloggen har en serie som heter Äventyr i dataorienterad design,och innehåller många användbara råd angående var DOD användes med bra resultat.

DICE verkar vara intresserad av dataorienterad design, eftersom de har använt den i Frostbite Engines utblåsningssystem (och har även betydande snabba uppgraderingar!). Några andra bilder från dem inkluderar även att använda dataorienterad design i AI-delsystemet - värt att titta på.

Utöver det verkar utvecklare som den tidigare nämnda Mike Acton omfamna konceptet. Det finns några riktmärken som visar att det ger mycket resultat, men jag har inte sett en hel del aktivitet på den dataorienterade designfronten på något tag. Det kan naturligtvis bara vara en kram, men dess huvudlokaler verkar mycket logiska. Det är säkert mycket inerti i den här verksamheten (och någon annan mjukvaruutvecklingsverksamhet, för den delen) så det kan hindra storskalig adoption av en sådan filosofi. Eller kanske är det inte så bra som det verkar vara. Vad tror du? Kommentarer är mycket välkomna!

Vidare läsning

  1. Dataorienterad design (eller varför du skulle kunna skjuta dig själv i foten med OOP)
  2. Introduktion till dataorienterad design [DICE] 
  3. En ganska fin diskussion om Stack Overflow 
  4. En online bok av Richard Fabian förklarar en hel del begreppen 
  5. En riktmärke som visar andra sidan av berättelsen, ett till synes kontraintuitivt resultat 
  6. Mike Actons översyn av OgreNode.cpp, som avslöjar några vanliga OOP-spelmotorutvecklingsfall