Hva er dataorientert spillmotordesign?

Du har kanskje hørt om data-orientert spillmotor design, et relativt nytt konsept som foreslår en annen tankegang til den mer tradisjonelle objektorienterte designen. I denne artikkelen vil jeg forklare hva DOD handler om, og hvorfor noen spillmotorutviklere føler at det kan være billetten for spektakulære resultatgevinster.

Litt historie

I de tidlige årene av spillutvikling ble spill og deres motorer skrevet på oldskolespråk, for eksempel C. De var et nisjeprodukt, og klemme hver siste klokkesyklus ut av treg maskinvare var på den tiden den ypperste prioriteten. I de fleste tilfeller var det bare et beskjedent antall personer som hakkede på koden til en enkelt tittel, og de kjente hele kodebasen av hjertet. Verktøyene de brukte hadde tjent dem godt, og C leverte ytelsesfordeler som gjorde dem i stand til å få mest mulig ut av CPU-en, og da disse spillene fortsatt var stort bundet av CPU, tegnet til sine egne rammebuffere, dette var et veldig viktig poeng.

Med adventen av GPUer som gjør det tallerknende arbeidet på trekanter, texler, piksler og så videre, har vi kommet for å avhenge mindre av CPU. Samtidig har spillindustrien sett stabil vekst: flere og flere mennesker vil spille flere og flere spill, og dette har igjen ført til at flere og flere lag kommer sammen for å utvikle dem. 

Moores lov viser at maskinvareveksten er eksponentiell, ikke lineær i forhold til tid: dette betyr at hvert år, antall transistorer vi kan passe på et enkelt bord, ikke endres med en konstant mengde - det dobler!

Større lag trengte et bedre samarbeid. Før lenge krevde spillmotorer, med kompleks nivå, AI, culling og rendering logikk kodene å være mer disiplinert, og deres valgfrie våpen var objektorientert design.

Som Paul Graham sa en gang: 

Ved store selskaper har programvare tendens til å være skrevet av store (og ofte skiftende) lag mediokre programmører. Objektorientert programmering pålegger disse programmene disiplin som forhindrer at noen av dem gjør for mye skade.

Enten vi liker det eller ikke, dette må være sant til en viss grad - større selskaper begynte å distribuere større og bedre spill, og da standardiseringen av verktøyene dukket opp, ble hackere som jobber med spill, deler som kunne byttes ut lettere. Dyden til en bestemt hacker ble mindre og mindre viktig.

Problemer med objektorientert design

Selv om objektorientert design er et fint konsept som hjelper utviklere til store prosjekter, for eksempel spill, lager flere lag av abstraksjon og få alle til å jobbe på mållaget, uten å måtte bry seg om implementeringsdetaljer av dem under, er det bundet til gi oss noen hodepine.

Vi ser en eksplosjon av parallelle programmerings-kodere som høster alle prosessorkjernene som er tilgjengelige for å levere lynrask beregningshastighet, men samtidig blir spillområdet mer og mer komplekst, og hvis vi vil holde tritt med den trenden og fremdeles levere rammene -På sekunder som spillerne forventer, må vi også gjøre det. Ved å bruke all hastighet vi har for hånden, kan vi åpne dører for helt nye muligheter: bruk av CPU-tiden for å redusere antall data som sendes til GPU-en, for eksempel.

I objektorientert programmering holder du tilstanden innenfor et objekt, noe som krever at du presenterer konsepter som synkroniserings primitiver hvis du vil jobbe med det fra flere tråder. Du har ett nytt nivå av indirection for hver virtuell funksjon du ringer til. Og minnetilgangsmønstre generert av kode skrevet på en objektorientert måte kan vær forferdelig-faktisk, Mike Acton (Insomniac Games, ex-Rockstar Games) har et flott sett med lysbilder som forklare et eksempel. 

På samme måte setter Robert Harper, professor ved Carnegie Mellon University, den på denne måten: 

Objektorientert programmering er [...] både antimodulær og parallell av sin natur, og dermed uegnet for en moderne CS-læreplan.

Å snakke om OOP som dette er vanskelig, fordi OOP omfatter et stort spekter av egenskaper, og ikke alle er enige om hva OOP betyr. I denne forstand snakker jeg for det meste om OOP som implementert av C ++, for det er for tiden språket som dominerer spillmotorverdenen.

Så, vi vet at spillene må bli parallelle fordi Det er alltid mer arbeid som CPU kan (men ikke trenger) å gjøre, og utgifter sykluser venter på GPU å fullføre behandlingen er bare sløsing. Vi vet også at vanlige OO-designtilnærminger krever at vi introduserer dyrt låsebestemmelse, og samtidig kan det krenke cacheplatsen eller forårsake unødvendig forgrening (som kan være kostbart!) Under de mest uventede omstendighetene.

Hvis vi ikke benytter flere kjerner, fortsetter vi å bruke samme mengde CPU-ressurser, selv om maskinvaren blir vilkårlig bedre (har flere kjerner). Samtidig kan vi skyve GPU til sine grenser fordi det er, ved design, parallelt og i stand til å ta på seg noe arbeid samtidig. Dette kan forstyrre vårt oppdrag å gi spillerne den beste opplevelsen på maskinvaren, da vi tydeligvis ikke bruker det til fullt potensial.

Dette reiser spørsmålet: bør vi tenke på våre paradigmer helt og holdent?

Skriv inn: Dataorientert Design

Noen forutsetninger for denne metoden har kalt det data-orientert design, men sannheten er at det generelle konseptet har vært kjent for mye lenger. Dens grunnleggende premiss er enkel: konstruere koden din rundt datastrukturene, og beskriv hva du vil oppnå når det gjelder manipulasjoner av disse strukturene

Vi har hørt denne typen snakk før: Linus Torvalds, skaperen av Linux og Git, sa i en Git-postliste at han er en stor forutsetning for å "lage koden rundt dataene, ikke omvendt", og kreditter dette som en av grunnene til Gits suksess. Han fortsetter selv å påstå at forskjellen mellom en god programmerer og en dårlig er om hun bekymrer seg for datastrukturer, eller selve koden.

Oppgaven kan virke counterintuitive først, fordi det krever at du setter din mentale modell opp og ned. Men tenk på det på denne måten: Et spill, mens du kjører, fanger opp alle brukerens innspill, og alle ytelse-tunge stykker av den (de der det ville være fornuftig å dike standarden alt er et objekt filosofi) ikke stole på eksterne faktorer, for eksempel nettverk eller IPC. For alt du vet, bruker et spill brukerarrangementer (mus flyttet, joystick-knappen trykket osv.) Og den nåværende spilltilstanden, og churns disse opp i et nytt sett med data, for eksempel batcher som sendes til GPU, PCM-prøver som sendes til lydkortet, og en ny spilltilstand.

Denne "data churning" kan brytes ned i mange flere delprosesser. Et animasjonssystem tar de neste keyframe-dataene og gjeldende tilstand og produserer en ny tilstand. Et partikkelsystem tar sin nåværende tilstand (partikkelposisjoner, hastigheter osv.) Og en tidsperspektiv og produserer en ny tilstand. En kollingsalgoritme tar et sett med kandidatgengivelser og produserer et mindre sett av gjengivelser. Nesten alt i en spillmotor kan tenkes som å manipulere en del av data for å produsere en annen del av data.

Prosessorer elsker lokalitet av referanse og utnyttelse av cache. Så, i datarientert design, har vi en tendens til, når det er mulig, å organisere alt i store, homogene arrays, og også, når det er mulig, kjøre gode, cache-koherente brute-force algoritmer i stedet for en potensielt mer avansert (som har en bedre Big O-kostnad, men unnlater å omfavne arkitekturen begrensninger av maskinvare det fungerer på). 

Når det utføres per ramme (eller flere ganger per ramme), gir dette potensielt store ytelsesbelønninger. For eksempel rapporterer folkene på Scalyr å søke loggfiler ved 20 GB / sek ved hjelp av en nøye utformet, men en naiv lydende brute-force lineær skanning. 

Når vi behandler objekter, må vi tenke på dem som "svarte bokser" og kalle deres metoder, som igjen får tilgang til dataene og får oss det vi vil ha (eller gjøre endringer som vi ønsker). Dette er flott for arbeid for vedlikehold, men ikke å vite hvordan dataene våre er lagt ut kan være skadelig for ytelsen.

eksempler

Data-orientert design har oss til å tenke alt om data, så la oss gjøre noe også litt annerledes enn det vi vanligvis gjør. Vurder denne delen av koden:

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

Selv om forenklet mye, er dette vanlige mønsteret det som ofte ses i objektorienterte spillmotorer. Men vent - hvis mange gjengivelser ikke er synlige, løper vi inn i mange forgreninger i grenen, som forårsaker at prosessoren slipper noen instruksjoner som den hadde henrettet i håp om at en bestemt gren ble tatt. 

For små scener er dette åpenbart ikke et problem. Men hvor mange ganger gjør du denne spesielle tingen, ikke bare når du kjøper gjengivelser, men når det skjer gjennom scenelys, skyggekart deler seg, soner eller lignende? Hva med AI eller animasjonsoppdateringer? Multipliser alt du gjør gjennom hele scenen, se hvor mange klokkeslett du utdriver, beregne hvor mye tid prosessoren har tilgjengelig for å levere alle GPU-batchene for en jevn 120FPS-rytme, og du ser at disse tingene kan skala til en betydelig mengde. 

Det ville være morsomt hvis en hacker som jobber på en webapp selv betraktet slike små mikrooptimeringer, men vi vet at spill er sanntidssystemer der ressursbegrensninger er utrolig stramme, så dette hensynet er ikke feilplassert for oss.

For å unngå at dette skjer, la oss tenke på det på en annen måte: hva om vi holdt listen over synlige gjengivelser i motoren? Visst, vi ville ofre den ryddige syntaksen av myRenerable-> skjule () og krenke ganske mange OOP-prinsipper, men vi kan da gjøre dette:

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

Hurra! Ingen gren misforståelser, og antar mVisibleRenderables er en fin std :: vektor (som er en sammenhengende rekkefølge), kunne vi også ha omskrevet dette som et raskt memcpy ring (med noen ekstra oppdateringer til våre datastrukturer, sannsynligvis).

Nå kan du ringe meg ut på den rene cheesiness av disse kodeprøven, og du vil være helt riktig: dette er forenklet mye. Men for å være ærlig har jeg ikke engang skrapet overflaten ennå. Å tenke på datastrukturer og deres relasjoner åpner oss for en rekke muligheter vi ikke har tenkt på før. La oss se på noen av dem neste.

Parallellisering og vektorisering

Hvis vi har enkle, veldefinerte funksjoner som opererer på store datastøtter som grunnblokk for behandling, er det enkelt å gyte fire eller åtte eller 16 arbeidstråd og gi hver av dem et stykke data for å holde hele CPU-en kjerner opptatt. Ingen mutexes, atomikk eller låsebestemmelse, og når du trenger dataene, trenger du bare å delta på alle trådene og vente på at de skal fullføres. Hvis du trenger å sortere data parallelt (en svært vanlig oppgave når du forbereder ting som skal sendes til GPU), må du tenke på dette fra et annet perspektiv - disse lysbildene kan hjelpe.

Som en ekstra bonus, i en tråd kan du bruke SIMD vektor instruksjoner (for eksempel SSE / SSE2 / SSE3) for å oppnå et ekstra hastighetsforhøyelse. Noen ganger kan du bare oppnå dette ved å legge dataene dine på en annen måte, for eksempel å plassere vektorfeltene i en struktur-of-arrays (SoA) måte (som XXX ... YYY ... ZZZ ... ) i stedet for den konvensjonelle array-of-strukturer (AoS; det ville være XYZXYZXYZ ... ). Jeg knapt kniper overflaten her; Du finner mer informasjon i Videre lesning delen nedenfor.

Når våre algoritmer håndterer dataene direkte, blir det trivielt å parallellisere dem, og vi kan også unngå noen hastighets ulemper.

Enhetstester du ikke visste var mulig

Å ha enkle funksjoner uten eksterne effekter gjør dem enkle å testes. Dette kan være spesielt godt i en form for regresjonstesting for algoritmer du vil bytte inn og ut lett. 

For eksempel kan du bygge en testpakke for en utføringsalgoritms oppførsel, sette opp et orkestrert miljø, og måle nøyaktig hvordan den utfører. Når du utarbeider en ny culling-algoritme, kjører du den samme testen igjen uten endringer. Du måler ytelse og korrekthet, slik at du kan få vurdering på fingertuppene. 

Når du får mer inn i dataorienterte designtilnærminger, vil du finne det enklere og enklere å teste aspekter av spillmotoren din.

Kombinere klasser og objekter med monolitiske data

Data-orientert design er på ingen måte motsatt objektorientert programmering, bare noen av ideene sine. Som et resultat kan du ganske pent bruke ideer fra data-orientert design og fremdeles få mesteparten av abstraksjoner og mentale modeller du er vant til. 

Ta en titt, for eksempel på arbeidet med OGRE versjon 2.0: Matias Goldberg, hovedmålet bak dette arbeidet, valgte å lagre data i store, homogene arrays og ha funksjoner som itererer over hele arrays i motsetning til å jobbe med bare ett tidspunkt , for å få fart på Ogre. Ifølge en referanse (som han innrømmer er veldig urettferdig, men ytelsesfordelen målt ikke kan være bare på grunn av det) fungerer det nå tre ganger raskere. Ikke bare det - han beholdt mange av de gamle, kjente klassen abstraksjoner, så API var langt fra en fullstendig omskrivning.

Er det praktisk?

Det er mye bevis på at spillmotorer på denne måten kan og vil bli utviklet.

Utviklingsbloggen til Molecule Engine har en serie som heter Opplevelser i dataorientert design,og inneholder mange nyttige råd om hvor DOD ble satt til bruk med gode resultater.

DICE ser ut til å være interessert i dataorientert design, da de har brukt det i Frostbite Engines kullsystem (og fikk også betydelige hurtige oppgraderinger!). Noen andre lysbilder fra dem inkluderer også å bruke data-orientert design i AI-delsystemet, verdt å se på.

Dessuten synes utviklere som den nevnte Mike Acton å omfavne konseptet. Det er noen benchmarks som viser at det gir mye i ytelse, men jeg har ikke sett mye aktivitet på den dataorienterte designfronten i en stund. Det kan selvfølgelig bare være en kjepp, men dens hovedlokaler virker veldig logiske. Det er sikkert mye tröghet i denne virksomheten (og enhver annen programvareutviklingsvirksomhet, for den saks skyld), slik at dette kan hindre storskala vedtak av en slik filosofi. Eller kanskje er det ikke så bra som det ser ut til å være. Hva tror du? Kommentarer er veldig velkommen!

Videre lesning

  1. Data-orientert design (eller hvorfor du kan skyte deg selv i foten med OOP)
  2. Introduksjon til dataorientert design [DICE] 
  3. En ganske fin diskusjon om Stack Overflow 
  4. En online bok av Richard Fabian forklarer mye av konseptene 
  5. Et referanse som viser andre sider av historien, et tilsynelatende motintuitivt resultat 
  6. Mike Actons gjennomgang av OgreNode.cpp, avslørende noen vanlige OOP-spillmotorutviklingsgruver