DEA.FI

Pelimoottoritie

Moi taas.

Pitkään olen asian vierestä puhunut, mutta tällä kertaa päästään lopulta itse asiaan. Ainakin melkein. Sen kunniaksi kokeilussa on tälläinen vähän eksoottisempi kuvakulmakin. Katsotaan tuleeko tästä mitään. Plus nyt en enää saa kovalla työllä kirjoitettuja vuorosanojani hyvään paikkaan.

Tiesitkö muuten, että jokaisesta jaksosta löytyy ne vuorosanat, sekä lisätietoja ja tietynlaista itsearviointia nettisivuiltani? Linkki luultavasti videon alla. Jos tähän on vielä lisättävää tai helppoja vinkkejä, niin kommenttikenttä ollee auki.

PR-kommentteja ei pidä ottaa henkilökohtaisesti. PR-kommentteja ei pidä ottaa henkilökohtaisesti.

Toisaalta. Tiedän kyllä, että tuotantolaatua voisi parantaa monella tavalla. Mutta joskus pitää kuitenkin saada valmista, joten tällä hetkellä taso on tämä. Esim. tämä jakso oli tarkoitus kuvata paremmalla luonnonvalolla, mutta nyt oli tyytyminen tekovaloon ja kovaan kohinaan. Olin lykännyt tätä kuvaamista jo niin paljon.

Mutta asiaan...

Osana tätä uusinta iteraatiota peliprojektistani olen - itseni tuntien täysin odotetusti - harhautunut, en sivupolulle, vaan "lystikkäästi" peli-moottori-tielle koodailemaan pelimoottoria.

Pelimoottorit ovat todella laaja aihe, mutta jos miettii millaisia yleiskäyttöisiä järjestelmiä olen tässä projektissani tarvinnut, niin mieleen tulee ainakin

Ja kaikki pitää tietenkin itse tehdä. Jossain vaiheessa haluan ehdottomasti kertoa millaisen asset-putken tai syötteiden hallinnan toteuttanut vastaamaan omia vaatimuksiani, mutta tällä kertaa aloitan kertomaan mainloopista ja tallennuksista.

Jos aloitetaan ihan alusta, niin yksinkertaisimmillaan pelin mainloop on

while (true) { update(dt); render(); }

mutta ajattelin tällä kertaa tehdä asiat paremmin, ainakin osittain (fix your timestep). Haluan rakentaa monimutkaisen pelin, jossa on paljon laskenta-aikaa vievää simulaatiota. Tein hieman tapojeni vastaisesti pienen määrän taustatutkimusta, ja löysin sen yksinkertaisen totuuden, miten tämä tapahtuu.

while (true) { update(dt); update(dt); update(dt); render(); }

Säikeistämällä simulaatiovaihetta mahdollisimman paljon päästään hyödyntämään "modernin" raudan kaikki ytimet.

while (true) { update(dt); render(); }

Ja siirtämällä simulaatio ja renderöinti tapahtumaan samaan aikaan maksimoidaan molemmille käytettävissä oleva aika kokonaiseksi ruuduksi. Kerrassaan yksinkertaista, eikö vain.

Käytäntöhän on toki aivan eri asia sitten. Pelkästään jo tuon yksinkertaisen päivitysfunktion voi toteuttaa niin monella eri tavalla. Meinasin siis tässä samalla innovoida vähän astetta enemmän ja tehdä jotain hienoa silläkin osastolla.

Tähän väliin tosin lienee tosin hyvä mainita siitä, että minulla ei lopulta ole kovin paljoa kokemusta peleistä, mutta halua ihan vaan kokeilla, että miltä maailma näyttää jos vähän kokeilee.

Eli pohja-ajatuksenani on siis oliopohjainen järjestelmä, ja aluksi ilman säikeitä. Kuitenkin koko ajan rakentaen järjestelmää siten, että kaiken voisi missä vain pisteessä säikeistää. Debuggaus on helpompaa kun kaikki tapahtuu peräkkäin, mutta suurimpana tekijänä saan säästettyä hieman työtä ja ehkä sitä itse peliäkin koodattua. Kohta lisää olioista, mutta ensin lisää mainloopista.

Kaikki pelin tilan päivitys ja piirto siis tapahtuu yksittäisten olioiden päivityksen ja piirtämisen kautta. Eli siellä mainloopissa ei ole erikseen, että ship.Update(dt), vaan on lista olioita, jotka sitten päivitetään. Kuinka mullistavaa /s. Mutta twistinä nyt siis se, että olioiden tila on jo lähtökohtaisesti kuvattu niin, että oliota voidaan samaan aikaan päivittää ja piirtää. Lisäksi nyt kun montaa eri oliota voidaan päivittää samaan aikaan, niin yhtä oliota varsinaisesti siis muokataan vain yhdellä säikeellä kerrallaan.

Yksinkertainen universaali totuus säikeistykseen liittyen on se, että samaa dataa ei voi kirjoittaa ja lukea samaan aikaan. Eli toisin sanoen päivitys ja piirtäminen eivät voi käyttää samaa dataa. Tietoa pitää siis kopioida. Mutta kuinka tehdä tämä mahdollisimman suorituskykyisesti ja mahdollisimman pienellä vaivalla itse kehittäjälle? Ja voisiko pelin tilan tallentamisen ja lataamisen integroida tähän kaikkeen? Ja vieläpä taaksepäin yhteensopivassa muodossa helpottaen pelin päivittämistä?

Spoiler: kyllä voi, mutta on parempiakin tapoja. Kirjoitin tämän kässärin jo aiemmin, mutta tämä taustoitus on tärkeää parannellun version ymmärtämiseksi.

Päästään joka tapauksessa siis siihen, mitä olen/olin jonkun tovin puuhaillut. Joku tarkkaan toimiani seurannut ehkä tietääkin, että olen käyttänyt koodigenerointia monessa asiassa, ja uskon sen tälläkin kertaa olevan osa ratkaisua. Elämme nyt lisäksi siitä hyvin poikkeuksellista aikaa, että .NET Coreen tuli vähän aikaa sitten Source Generators V2 niminen ominaisuus, joka merkittävästi helpottaa tätä tointa.

Yksinkertaistettuna: olioiden datan rakenne kuvataan siten, että niiden alustukseen ja varsinaiseen vaihtuvaan tilaan kuuluvat ominaisuudet on eroteltu, ja vaihtuvan tilan muuttujat automaattisesti kahdennettu piirtämistä varten. Kun alustukseen liittyviä muuttujia ei kopioida, säästyy muistia.

Mutta entä se itse tilan kopioiminen sitten? Siinä onkin homman hienous! En tosin ole varma, onko mitä asia lopulta tarkoittaa puhtaan suorituskyvyn kannalta, mutta minulla on hieno suunnitelma, jota kävin koestamaan. Tästä kohta lisää, alustetaan suhteellisen yksinkertainen esimerkki ensiksi:

Missile int Damage Vector2 Velocity

State Render Vector2 Position Vector2 Position float TimeToLive float TimeToLive float Health float Health

Ohjuksen vahinko ja nopeus eivät muutu sen eliniän aikana. Sen sijaan sijainti ja elinaika muuttuvat koko ajan. Lisäksi ohjukseen saatetaan välillä tehdä vahinkoa kesken lennon. Koska piirto tapahtuu toisessa säikeessä, pitää nämä tilaparametrit monistaa, ja sitten sopivassa kohden aina synkronoida. Voi myös olla, että jokin toisessa säikeessä päivitettävä pelin järjestelmä haluaa pitää kirjaa kaikkien lennossa olevien ohjusten sijainneista ja voinnista, joten tätä kopiota tilasta tarvitaan myös sen tarpeisiin.

Ja nyt päästää siihen itse pihviin. Saas nähdä osaanko selostaa tämän kuinka hyvin...

Yksinkertaisimmillaan tämä synkronointi tuossa aiemmassa säiemaailmassa tapahtuu siten, että odotetaan, että tämänkertainen päivitys ja piirto saadaan valmiiksi. Kun molemmat ovat pysähdyksissä, niin tiedetään, ettei kukaan muu lue eikä kirjoita dataa, joten voidaan kopioidaan päivityksen tila piirron tilaksi, ja jatkaa simulointia ja piirtämistä seuraavan ruudun osalta uudella datalla.

Koodigeneroinnilla tuon kahden eri tilan hallinta muuttuu automaattiseksi. Mutta miksi lopettaa vielä siihen! Entäs jos joitain tilan kenttiä ei aina päivitetäkään? Voisiko ne jättää kopioimatta? Ja voisiko kopioinnin ylipäänsä suorittaa päivityksen aikana, paradoksaalisesti kuitenkaan ylikirjoittamatta tuota render-tilaa?

Vastaus näihin on kyllä! :>

Sen sijaan, että nuo kaksi tilakentän kopiota ovat aina yhdessä roolissa, niin väliin voidaan laittaa yksi osoitinkerros, joka määrääkin käyttötarkoituksen.

Tuotantolaatuvaroitus

Lisätään malliin siis bittikenttä kertomaan se, missä on kullakin hetkellä uusin arvo päivitystä varten. Ja kopio maskista ohjaamaan sitä, mistä piirron pitää lukea data. Tällöin tämä varsinainen roolitus näiltä kentiltä katoaa. Kun nyt kentän arvoa päivitetään, luetaan edellisen tilan arvo (joka on aluksi sama mitä ollaan piirtämässä), ja kirjoitetaan uusi arvo siihen kenttään, jota ei olla piirtämässä. Merkitään päivitysmaskiin se uuden arvon sijainniksi. Nyt tilan synkronointiin kunkin olion osalta riittää kopioida yksi bittimaski toiseen. Ja mikäli jotain kenttää ei muutettu, ei tuon yhden bittimaskin bitin lisäksi kopioitu mitään mihinkään.

Nerokasta, sanoisin!

Tästä tosin päästään siihen mainitsemaani, eli en ole varma mitä tämä tarkoittaa lopulta suorituskyvyn kannalta. Tuo epäsuoruus bittimaskin kautta voi vaikuttaa tiedon prosessointinopeuteen, kun kaikki data ei ole niin lineaarisesti CPU:n saatavilla. Toisena seikkana tässä mallissa on selkeä haaste vaihtuvankokoisen tiedon kanssa. Molempiin tilarakenteisiin on allokoitava maksimaalisen kokoinen puskuri, tai sitten allokoitava joka muutosta varten uusi. Molemmilla on omat huonot puolensa, eikä paljoakaan hyviä.

Toisaalta mikäli data on sellaista, että se varmasti elää vain yhden ruudun ajan (kuten esimerkiksi näytönohjaimelle ladattava geometridata), on mahdollista varata sille tilaa ruutukohtaisesta lineaarisesta puskurista. Tästä ehkä myöhemmin lisää. Pysy kanavalla :)

Lisäksi kuten mainitsin, mikäli jonkin olion on käsiteltävä sen itsensä ulkopuolista dataa (esimerkiksi ohjuksen hakeutumista varten), voi se turvallisesti käyttää muiden olioiden piirto-puolen dataa.

Yhteenvetona tämä mallini siis mahdollistaa päivityksen säikeistämisen suhteellisen minimaalisella synkronointioverheadilla. Hintana kuitenkin luonnollisesti kaksinkertainen muistinkäyttö muuttuvien kenttien osalta ja tuo yhden ruudun viive muiden olioiden tilan tarkastelussa.

Tätä viivettä minimoidakseni tähän malliin kuulu se, että kullakin oliolla voi olla lapsia, joilla on aina pääsy vanhempansa uusimpaan tilaan. Eli siis vaikka aluksen tykit ovat lapsia itse aluksessa, ja tykkien tila päivitetään aluksen päivityksen jälkeen. Mutta hintana on se, että tälläinen ketju ei voi käyttää samaan aikaan montaa säiettä. Joka tapauksessa ohjelmoijan ei tarvitse asian tiedostamisen lisäksi huomioida järjestelmää mitenkään. Järjestelmä generoi automaattisesti propertyt, joita käyttämällä homma toimii automaattisesti niin päivityksen kuin piirronkin osalta.

Ja kun koodigenerointia kerran on käytössä, niin lähes samalla vaivalla järjestelmän voisi koodata muitakin hienoja asioita. Kuten vaikka olioiden poolaus toimimana täysin automaattisesti, joskaan en ole sitä vielä ehtinyt tehdä. Tällä siis vältetään jatkuvat muisti-allokaatiot sellaisille olioille, joita on paljon, mutta jotka kuitenkin elävät suhteellisen lyhyen aikaa.

Mutta tältä se kaikki näyttää käytännössä:

Metadata, Renderer, Update() #################################### #################################### #################################### #################################### ####################################

Eli kullekin olion luokalle kirjoitetaan sisäkkäinen luokka, jossa on kuvattu datan rakenne. Tässä näkyy alustuksen tila, että muuttuva tila. Ja tämän perusteella sitten generoidaan automaattiset propertyt siihen ylätason olioon, sekä oliossa olevaan piirron alaluokkaan. Ja toki myös se monistuskoodi. En nyt näytä esimerkkejä siitä miltä lopullinen generoitu koodi näyttää, sillä nyt seuraa sen aiemmin väläytetyn juonipaljastuksen kohta.

Kuten siis spoilasin, niin tähän on vielä parempiakin tapoja. Tässä alkuperäisessä ajatuksessa oli suurena vaikuttajana se, että piirtopuoli myös generoi varsinaiset piirokomennot, jolloin sillä oli oltava pääsy olion täyteen tilaan. Melko nopeasti kuitenkin huomasin, että suorituskyvyn kannalta vieläkin parempi tapa on generoida piirtokomennot suoraan päivityksen yhteydessä ja siirtää ainoastaan komentojen suoritus piirtosäikeeseen.

Tämän muutoksen myötä piirrossa tarvitaan huomattavasti vähemmän tilaa, jolloin tämä yllä oleva ajatus tilan monistamisesta koituu kovin tarpeettomaksi. Monistettavaksi jää enää vain ne harvat kentät, joita muut oliot saattavat tarvita. Sekä toki olion senhetkinen sijainti, joka päivittyy yleensä ottaen joka ruutu.

State, Render, Proxy #################################### #################################### #################################### #################################### ####################################

Tässä ensimmäisenä esimerkkinä kaasupilvi, jolla on muutama tilakenttä. Pari tylyä ajastinta, sekä sitten sen koko. Tässä tapauksessa koko on sellainen, jota myös muut oliot haluavat lukea, joten ainoastaan se kopioidaan.

Toisessa esimerkissä ainoastaan laserpyssyn säteen sijaintia tarvitaan (vielä toistaiseksi) piirrossa, joten se kopioidaan sinne.

Kuten ehkä näkyy, niin yleensä ottaen näitä muiden tarvitsemia kenttiä on hyvin vähän, joten monistaminen muuttuukin äkkiä kovin harvinaiseksi. Lisäksi nykyisellään koodigeneraattoreissa on yleisesti suuria vakausongelmia. Näiden ongelmien myötä tapahtuu hyvin usein sitä, ettei kenttiä saada luettua siitä vanhanmallisesta Metadatasta, ja sitten näyttää siltä, ettei oliolla ole lainkaan tilakenttiä ja tuloksena satoja käännösvirheitä editorissa. Build nappula toimisi edelleen.

Joten yksinkertaistin toteutusta ja poistin tuon maskipohjaisen kopioinnin. Sen sijaan kaikki tarpeelliset kentät kopioida joka kerta. Ja kuten sanottua, niin siirsin propertyt suoraan itse olioon. Ehkä tulevaisuudessa olisi mahdollista palata taas hienostelemaan maskien kanssa jos se on tarpeen, nyt kun idea on koestettu.

Mutta tällaista tällä erää. Seuraavassa osassa olisi loogista katsoa miten noita olioita sitten käytetään, ja miten tuo kopiointi toimii nykyään. Tai sitten jotain muuta. Mutta ehkä koodia?

Edelleenkään en tosin osaa edes arvailla milloin se seuraava video tulee. Tilaa kanava, niin tulevaisuutesi on turvattu! Toisaalta latailen tänne myös jotain turhaakin paskaa aina silloin tällöin, et mikäs minä olen sanomaan.

Nähdään seuravalla kertaa! Moi moi.