Fysiikkaa peliohjelmoijille - Osa 1: Sijainti ja liike
Realistinen fysiikan mallinnus on noussut realistisen grafiikan rinnalle tärkeäksi elementiksi peleissä. Tämä artikkelisarja esittelee fysiikan mallinnuksen perusteet 2D- ja 3D-avaruudessa. Keskitymme erityisesti kiinteiden kappaleiden mekaniikkaan. Tämä on artikkelisarjan ensimmäinen osa.
27.11.2004 julkaistun artikkelin on kirjoittanut markus.
1. Johdanto #
Fysiikan mallinnus on tärkeässä osassa kaikissa uusissa peleissä. Realistinen fysiikka lisää peliin jopa enemmän realismia kuin realistinen grafiikka. Se tuo peliin vahvan paikalla olon tunteen. Tämä artikkeli opettaa sinulle fysiikkaa peliohjelmointia varten. Käsittelemme fysiikkaa sekä 3D-avaruudessa että 2D-avaruudessa, jossa asiat muuttuvat huomattavasti helpommiksi. Keskitymme erityisesti kiinteiden kappaleiden mekaniikkaan. Tarkemmin klassiseen eli Newtonin mekaniikkaan. Kiinteä kappale (englanniksi rigid body) on kappale, jonka muoto ei muutu. Se ei siis törmätessään hajoa tai painu kasaan. Mekaniikka taas tarkoittaa kappaleiden välistä vuorovaikusta voimien ja törmäysten välityksellä. Newtonin mekaniikka tarkoittaa mekaniikkaa, joka pätee arkipäisillä mittasuhteilla vaikka luodeille, jalkapalloille, ihmisille ja autoille. Se ei siis toimi atomien tai galaksien mittakaavassa.
Tämä artikkelisarja tulee alustavien suunnitelmien mukaan sisältämään seuraavat osat.
1. Sijainti ja liike (tämä artikkeli)
2. Kiihtyvyys ja voimat
3. Törmäykset
4. Lepokontakti ja kitka
Asioiden yksinkertaistamiseksi oletamme, että kaikki kappaleet ovat joko palloja (ympyröitä 2D-avaruudessa) tai suorakulmaisia särmiöitä eli laatikoita (suorakulmioita 2D-avaruudessa). Vaikka esittelemme vain nämä kaksi muotoa, voidaan tekniikat yleistää minkä muotoisiin kappaleisiin tahansa. Tämä artikkeli ei ota kantaa siihen kuinka palloja tai laatikoita piirretään.
Luomme tässä artikkelissa kaksi luokkaa tai pidemminkin rakennetta: RIGIDBODY2D ja RIGIDBODY3D. Nämä rakenteet pitävät sisällään kaikki tarvittavat tiedot kiinteästä kappaleesta sen simuloimiseen. Alussa, kun rakenteet sisältävät vain kappaleen muodon ja koon, ne näyttävät tältä:
typedef struct
{
int shape; // Muoto. 0=ympyrä ja 1=suorakulmio
float radius[2]; // ”Säde” metreinä.
} RIGIDBODY2D;
typedef struct
{
int shape; // Muoto. 0=pallo ja 1=laatikko
float radius[3]; // ”Säde” metreinä.
} RIGIDBODY3D;
Täydennämme näitä rakenteita sitä mukaa kun artikkelit etenevät. Käytämme tässä artikkelissa standardeja SI-yksiköitä eli pituudet ilmoitetaan metreinä, eikä esim. tuumina. Tämä artikkelisarja perustuu pitkälti David Baraffin vallan mainioon, joskin uskomattoman vaikealukuiseen artikkeliin ”Rigid Body Dynamics”.
2. Sijainti ja asento #
Ensimmäinen käsiteltävä asia on, kuinka esitämme kappaleen sijaintia ja asentoa 2D- tai 3D-avaruudessa. Tämä saattaa tuntua itsestään selvältä, mutta jo kappaleen asennon esittäminen 3D-avaruudessa on ongelmallinen.
Kiinteän kappaleen sijaintia (englanniksi position) esitetään sen (massa)keskipisteen koordinaattien avulla. Ei siis voisi olla paljon helpompaa. 2D-avaruudessa tarvitaan kaksi lukua: x ja y-koordinaatti ja 3D-avaruudessa vielä kolmas z-koordinaatti. Sijaintikoordinaattien yksikkönä käytämme metriä (lyhenne m). Kun lisäämme nämä tiedot rakenteisiimme niistä tulee seuraavanlaiset:
typedef struct
{
int shape; // Muoto. 0=ympyrä ja 1=suorakulmio
float radius[2]; // "Säde" metreinä.
float position[2]; // Sijainti metreinä
} RIGIDBODY2D;
typedef struct
{
int shape; // Muoto. 0=pallo ja 1=laatikko
float radius[3]; // "Säde" metreinä.
float position[3]; // Sijainti metreinä
} RIGIDBODY3D;
Ei ollut hankalaa. Entäpä asento (englanniksi orientation)? 2D-tapaus on helppo. Asento ilmoitetaan yhdellä kulmalla, joka ilmoittaa kuinka paljon kappale on pyörinyt alkuasennostaan. Tämän kulman yksikkönä käytämme tässä artikkelissa radiaania (lyhenne rad), emmekä esim. astetta. Kun lisäämme asennon RIGIDBODY2D-rakenteeseen tulee siitä seuraavanlainen:
typedef struct
{
int shape; // Muoto. 0=ympyrä ja 1=suorakulmio
float radius[2]; // Säde" metreinä.
float position[2]; // Sijainti metreinä
float orientation; // Asento radiaaneina
} RIGIDBODY2D;
3D-avaruudessa asento tuottaa varsin kiperän ongelman. Ensimmäisenä tulee mieleen tallentaa kolme eri kulmaa, yksi jokaisen akselin ympäri. Tätä tapaa kutsutaan Eulerin kulmiksi. Vaikka menetelmä saattaa aluksi vaikuttaa pomminvarmalta, on siinä muutama aukko. Esimerkiksi lopputulos riippuu siitä, missä järjestyksessä pyöritys tehdään, ja on mahdollista päätyä hyrrälukoksi (englanniksi gimbal lock) kutsuttuun jumitilanteeseen, jossa eri akselien kulmat kumoavat toisensa.
Koska 3D-avaruudessa pyöriminen tapahtuu aina jonkin akselin ympäri jonkin kulman verran, voimme tallentaa asennon akselina ja kulmana, siis neljä lukua. Yksi kolme komponenttinen yksikkövektori ilmoittamaan akselin suuntaa ja yksi luku ilmoittamaan kulmaa. Helpottaaksemme myöhemmin eteen tulevia laskelmia koodaamme nämä neljä lukua neljä komponenttiseksi kompleksiluvuksi eli kvaternioksi (englanniksi quaternion). Hienosta nimestä ja määritelmästään huolimatta kvaternio on varsin yksinkertainen tapaus. Kvaternio koostuu neljästä komponentista, joita merkitään yleensä symboleilla w, x, y ja z. Akseli (i, j, k) ja kulma α. Koodataan kvaternioksi seuraavasti (kun akselin pituudeksi oletetaan 1):
w=cos(α /2)
x=i*sin(α /2)
y=j*sin(α /2)
z=k*sin(α /2)
Eikä muuta tarvita. Kvaternion neljä komponenttia w, x, y ja z voidaan tallentaa vaikka neljä alkioiseksi taulukoksi ”float q[4]” niin, että q[0]=w, q[1]=x, q[2]=y ja q[3]=z. Kulman ja akselin muutos kvaternioksi saattaa vaikuttaa turhalta, mutta se helpottaa asioita myöhemmin. Nyt meillä on vihdoin keino tallentaa kappaleen asento 3D-avaruudessa ja voimme täydentää RIGIDBODY3D-rakennetta.
typedef struct
{
int shape; // Muoto. 0=pallo ja 1=laatikko
float radius[3]; // "Säde" metreinä.
float position[3]; // Sijainti metreinä
float orientation[4]; // Asento kvaterniona
} RIGIDBODY3D;
Vielä olisi yksi huomioitava asia. Haluamme varmaan laskea kappaleen pinnalla olevan pisteen absoluuttisen sijainnin jotakin tarkoitusta varten, kun tiedämme vain pisteen sijainnin suhteessa kappaleen keskipisteeseen ja kappaleen sijainnin ja asennon. Olkoon p kappaleen pinnalla sijaitsevan pisteen sijaintivektori (komponentit p.x, p.y (ja 3D-avaruudessa myös p.z)) suhteessa kappaleen keskipisteeseen. Olkoon s kappaleen keskipisteen sijaintivektori (jossa komponentit s.x, s.y ja s.z) ja φ kappaleen asentoa esittävä kulma tai kvaternio (jossa komponentit φ.w, φ.x, φ.y ja φ.z). Kuinka näistä tiedoista laskemme pisteen absoluuttisen sijainnin P (jossa komponentit P.x, P.y ja P.z)?
Jos huomioon otettaisiin pelkkä sijainti, kappaleen keskipiste yksinkertaisesti lisättäisiin pisteen koordinaatteihin ja meillä olisi näin absoluuttiset koordinaatit.
P.x = p.x + s.x
P.y = p.x + s.y
(P.z = p.z + s.z)
Asento sen sijaan tuottaa hieman lisää työtä. 2D-avaruudessa asennon vaikutus voidaan laskea käyttämällä pisteen pyörityskaavaa:
X = cos(α) * x – sin(α) * y
Y = sin(α) * x + cos(α) * y
Kun sovellamme äskeistä kaavaa ja lisäämme saatuihin koordinaatteihin vielä sijainnin saamme seuraavan lopullisen kaavan, joka laskee absoluuttisen sijainnin asennosta φ ja sijainnista s:
P.x = cos(φ) * p.x – sin(φ) * p.y + s.x
P.y = sin(φ) * p.x + cos(φ) * p.y + s.y
3D-avaruudessa asennon huomioon ottaminen mutkistuu huomattavasti. Tätä varten meidän pitää esitellä kvaternioiden kertolasku. Kaksi kvaterniota q1 ja q2 voidaan kertoa seuraavasti (q3=q1*q2):
q3.w = q1.w * q2.w - q1.x * q2.x - q1.cy * q2.y - q1.z * q2.z
q3.x = q1.x * q2.w + q1.x * q2.x - q1.z * q2.y + q1.y * q2.z
q3.y = q1.y * q2.w + q1.z * q2.x + q1.w * q2.y - q1.x * q2.z
q3.z = q1.z * q2.w - q1.y * q2.x + q1.x * q2.y + q1.w * q2.z
Huomaa, että kvaternioiden kertolasku ei ole ns. kommutatiivinen eli q1*q2 ei välttämättä ole yhtä paljon kuin q2*q1. Tämän takia on tärkeää noudattaa oikeaa laskujärjestystä. Sitten vielä sama koodina.
void multQuaternion(float result[4],
const float q1[4], const float q2[4])
{
result[0] = q1[0]*q2[0] - q1[1]*q2[1] –
q1[2]*q2[2] - q1[3]*q2[3];
result[1] = q1[0]*q2[1] + q1[1]*q2[0] +
q1[2]*q2[3] - q1[3]*q2[2];
result[2] = q1[0]*q2[2] + q1[2]*q2[0] +
q1[3]*q2[1] - q1[1]*q2[3];
result[3] = q1[0]*q2[3] + q1[3]*q2[0] +
q1[1]*q2[2] - q1[2]*q2[1];
}
Seuraava tarvittava asia on vektorin muuttaminen kvaternioksi. Vektori (i, j, k) muutetaan kvatenioksi (w, x, y, z) seuraavan kaavan mukaan:
w=0
x=i
y=j
z=k
Ja vastaavasti kvaternio voidaan muuttaa takaisin vektoriksi hylkäämällä kvatenion w-komponentti.
Viimeinen tarvittava asia on käänteiskvatenion laskeminen. Kvaterniosta voidaan ottaa käänteisluku muuttamalla sen x, y ja z komponenttien etumerkit. Eli kvaternion (w, x, y, z) käänteiskvaternio olisi (w, -x, -y, -z). Nyt tiedät kaiken tarvittavan. Pisteen p pyörittäminen kvaternion φ ilmoittamaan asentoon tehdään seuraavan kaavan mukaan.
P= vektoriksi(φ * kvaternioksi(p) * φ^-1)
Kun asennon vaikutus on laskettu edellisen kaavan avulla, pitää vielä lisätä sijainti, jolloin lopullinen kaava olisi:
P= vektoriksi(φ * kvaternioksi(p) * φ^-1) + s
Kvaternioiden laskuissa saattaa tapahtua pyöristysvirheitä, joiden eliminoimiseksi kvaternio pitää aika ajoin normalisoida. Tämä tehdään jakamalla sen kaikki komponentit sen pituudella. Seuraavassa normalisointi koodina:
float normalizeQuaternion(float q[4])
{
float m=sqrt(q[0]*q[0]+q[1]*q[1]+q[2]*q[2]+q[3]*q[3]);
if (m!=0)
{
q[0]/=m;
q[1]/=m;
q[2]/=m;
q[3]/=m;
}
return m;
}
3. Johdatus differentiaaliyhtälöihin #
Differentiaaliyhtälöt ovat yhtä vaikeita kuin miltä kuulostavatkin. Onneksi meidän ei tarvitse yrittää ratkaista sellaista, mutta meidän täytyy tietää mitä ne ovat. Kun olen esitellyt differentiaaliyhtälöt, saatat miettiä mihin niitä oikein tarvitaan. Palaset loksahtavat kuitenkin lopulta paikalleen.
Ensimmäinen ja tärkein asia on käsite nimeltä derivaatta (englanniksi derivative). Derivaatta tarkoittaa muutosta. Se ilmaiseen miten jokin muuttuu suhteessa johonkin toiseen. Otetaan esimerkiksi suora y(x)=2x. Ks. kuva.
Jos katsot suoraa, huomaat, että aina kun x kasvaa yhden yksikön, kasvaa y kaksi yksikköä. y:n derivaatta suhteessa x:ään on siis 2. Kaikilla suorilla derivaatta onkin sama kuin suoran kulmakerroin. Vaakasuoran suoran derivaatta olisi näin ollen nolla sillä vaikka x kasvaisi kuinka pysyy y aina samana. Otetaanpa seuraavaksi hieman monimutkaisempi yhtälö y(x)=x^2. ks. kuva.
Huomaat varmaan, että y ei kasva tasaisesti verrattuna x:ään vaan kuvaaja on toisaalla jyrkempi ja toisaalla loivempi. y:n derivaatta suhteessa x:n on siis eri suuri eri pisteissä. Derivaatta tietyssä pisteessä voidaan laskea piirtämällä pisteelle tangentti (kuvaajaa sivuava suora viiva) ja laskemalla tämän suoran kulmakerroin.
Koska tangenttien piirtely kynän ja viivoittimen kanssa on vähän hidas ja epätarkka menetelmä, on olemassa myös analyyttinen menetelmä laskea derivaatta. Tätä laskutoimitusta kutsutaan derivoinniksi (englanniksi derivation). Kun funktio derivoidaan, saadaan tulokseksi toinen funktio, joka ilmoittaa alkuperäisen funktion derivaatan halutussa pisteessä. Esim. funktion x^2 derivaatta on 2x. Eli pisteessä x=0 derivaatta olisi 2*0=0 ja pisteessä x=3 derivaatta olisi 2*3=6. Voit todeta tuloksen oikeellisuuden piirtämällä kuvaajalle y(x)=x^2 tangentit pisteisiin x=0 ja x=3. Ensimmäisen tangentin kulmakertoimeksi pitäisi tulla 0 ja toisen 6.
Derivaattaa merkitään pistämällä heittomerkki funktion nimen perään. Esim. Funktion f(x) derivaattaa merkittäisiin f’(x). Derivointi suoritetaan noudattamalla yksinkertaisia sääntöjä, joiden avulla käytännössä mikä tahansa funktio voidaan derivoida. Alla olevassa taulukossa on huvin vuoksi muutamia esimerkkisääntöjä. Sinun EI siis tarvitse osata derivoida, ainoastaan ymmärtää mitä derivaatta tarkoittaa.
Funktio Derivaatta kx k x^k kx^(k-1) Sin x Cos x
Nyt kun ymmärrät mitä derivaatta tarkoittaa voimme jatkaa, mutta koska derivaatta on niin tärkeä asia, niin ihan vaan vielä kertauksen vuoksi:
Derivaatta ilmaisee muutosta. Se ilmaiseen kuinka jokin muuttuu suhteessa johonkin, eli kuinka paljon jokin kasvaa aina, kun jokin toinen kasvaa yhden yksikön.
Tästä pääsemmekin differentiaaliyhtälön käsitteeseen. Tavallisessa yhtälössähän meidän pitää yrittää ratkaista jokin tuntematon muuttuja, jota yleensä merkitään kirjaimella x. Esim. Jos 2x-6=0 niin x=3. Differentiaaliyhtälössä tiedämme funktion derivaatan (tai ainakin jotain siitä), jonka perusteella yritämme ratkaista mikä se alkuperäinen funktio oli. Esim. tiedämme, että f’(x)=2x. Kysymme mitä on f(x). Jos muistat tämän kappaleen alun, muistat varmaan, että funktion x^2 derivaatta oli 2x. Eli tässä tehtävässä haettu funktiokin varmaan oli f(x)=x^2.
Toimenpidettä, jolla differentiaaliyhtälö ratkaistaan, kutsutaan integroinniksi. Integrointi yrittää siis päätellä funktion derivaatasta alkuperäisen funktion. Derivoinnin tapaan myös integrointi suoritetaan käyttämällä yksinkertaisia sääntöjä. Valitettavasti integrointi on huomattavasti hankalampaa kuin derivointi ja vain hyvin yksinkertaiset differentiaaliyhtälöt voidaan ratkaista analyyttisesti. Sen sijaan on mahdollista, jopa helppoa, yrittää numeerisesti arvioida integraalin arvoa tietyssä pisteessä. Ja mikä olisi sen parempi työkalu numeerisiin menetelmiin kuin tietokone. Tästä pääsemmekin seuraavaan lukuun: ”Numeerinen integrointi”. Yritä kestää vielä hetki. Kaiken tämän matemaattisen sekasotkun tarkoitus selviää seuraavan pitkähkön kappaleen jälkeen.
4. Numeerinen integrointi #
Alkuperäisen funktion päätteleminen sen derivaatasta, eli integrointi, on siis varsin hankala prosessi. Tarkoitusperiämme varten emme ole kuitenkaan kiinnostuneita koko alkuperäisestä funktiosta vaan ainoastaan sen arvosta tietyssä pisteessä.
Integrointi ja derivointi eivät ole aivan täydellisesti vastakkaisia toimintoja. Derivoinnissa nimittäin menetetään vakiotermi, jota integroinnissa ei voi palauttaa. Esim. funktioiden x^2+1, x^2+2, x^2+3 jne. kaikkien derivaatta on 2x. Emme täten voi derivaatasta 2x mitenkään päätellä täydellisesti mikä alkuperäinen funktio oli. Se on x^2+jotain. Tämän takia differentiaaliyhtälön ratkaisussa tarvitaan derivaatan lisäksi tietää yksi piste funktiolta f(x) ns. alkuarvo (englanniksi initial value). Täytyy siis tietää mitä on f(0). Jos tiedämme, että funktion f(x) derivaatta on 2x ja f(0) on 1, voimme yksikäsitteisesti päätellä, että f(x) on x^2+1, koska nyt f(0) on 0^2+1=1, eli alkuarvo toteutuu.
Numeerinen integraattori on tietokoneohjelma, joka saa syötteenään funktion f(x) derivaatan f’(x), alkuarvon f(0) ja pyrkii näiden perusteella arvioimaan paljonko f(x) on jollain tietyllä x:n arvolla. Esittelen kolme erilaista numeerista integraattoria helpoimmasta vaikeampaan. Ne ovat Eulerin menetelmä, keskipistemenetelmä ja Runge-Kutta-menetelmä. Katsotaanpa ensin miltä numeerisen integraattorin prototyyppi näyttäisi.
float integrate( float initialValue, float x,
float (*derive)(float value, float x) );
Se ottaa parametrinaan alkuarvon ”initialValue”, arvon x ja osoittimen funktioon ”derive”, jota kutsumalla se saa selville derivaatan f'(x) kohdassa x, jossa funktion f(x) arvo on "value". Lopuksi funktion palauttaa arvion siitä paljonko f(x) on annetulla x:n arvolla. Entäpä jos haluaisimme kokeilla integrointia useilla eri alkuarvoilla? Voisimme tietenkin kutsua funktiota jokaiselle arvolle erikseen, mutta helpompaa olisi, jos integraattori ottaisi kaikki arvot kerralla taulukkona. Lisäksi funktio "derive" saattaa tarvita funktion f(x) arvon ja x:n lisäksi jotain muuta tietoa derivaatan laskemiseksi, joten lisätään integraatoriimme vielä yksi parametri "userData", jonka avulla käyttäjä voi välittää integraattorille mielivaltaisen osoittimen. Optimoinnin kannalta olisi parempi, jos integraattorin mahdollisesti tarvitsema väliaikainen muistitila varattaisiin integraattorin ulkopuolella ja annettaisiin osoittimena integraattorille. Niinpä lisäämme integraattoriin vielä osoittimen ”temp”. Tehdäänpä nyt nämä muutokset integraattoriin.
void integrate( float *result, const float *initialValue,
DWORD size, float x,
void (*derive)(float *result,
const float *value,
float x, void *userData),
float *temp, void *userData );
Nyt integraattori ottaa parametrinaan osoittimet taulukoihin ja taulukossa olevien alkioiden määrän "size".
4.1 Eulerin menetelmä
Eulerin menetelmä on hyvin yksinkertainen. Itseasiassa niin yksinkertainen, että olet ehkä joskus käyttänyt sitä tietämättäsi, mutta siitä enemmän kappaleessa 6. Eulerin menetelmä integroi seuraavan kaavan mukaisesti.
f(x)≈f(0)+f'(0)*x
Geometrisesti tämä tarkoittaa sitä, että seuraamme funktion tangenttia alkupisteestä eteenpäin.
Kuten kuvastakin näkee tangentti erkanee oikeasta kuvaajasta, joten tällä menetelmällä saatu tulos on kuin onkin vain likiarvo. Seuraavassa Eulerin menetelmä koodina. Huomaa, että temp tarvitsee yhtä monta alkiota kuin syötekin.
void integrate( float *result, const float *initialValue,
DWORD size, float x,
void (*derive)(float *result,
const float *value,
float x, void *userData),
float *temp, void *userData )
{
int i;
// Kysy paljonko f'(0) on
derive( temp, initialValue, 0, userData );
// Laske f(0)+x*f'(x)
for ( i=0; i<size; i++ )
result[i]=initialValue[i]+x*temp[i];
}
4.2 Keskipistemenetelmä
Keskipistemenetelmä integroi seuraavan kaavan mukaisesti.
f(x)≈f(0)+x*f'(x/2)
Se eroaa Eulerin menetelmästä siinä, että se ottaa derivaatan (tangentin suunnan) puolivälistä, eikä alusta. Tästä tulee nimi keskipistemenetelmä. Asia ei kuitenkaan ole aivan näin yksinkertainen. Nimittäin, jotta "derive"-funktio osaisi laskea paljonko on f'(x/2), sen ehkä tarvitsee tietää paljonko on f(x/2). Tämän keskipistemenetelmä selvittää Eulerin menetelmän avulla.
Seuraavassa keskipistemenetelmä koodina. Huomaa, että "temp" tarvitsee kaksi kertaa enemmän alkioita kuin niitä on syötteessä.
void integrate( float *result, const float *initialValue,
DWORD size, float x,
void (*derive)(float *result,
const float *value,
float x, void *userData),
float *temp, void *userData )
{
int i;
float *temp2;
temp2=temp+size;
// Tutki Eulerin menetelmän avulla paljonko on f(x/2)
// Kysy paljonko on f'(0)
derive( temp, initialValue, 0, userData );
// laske f(0)+(x/2)*f'(0)
for ( i=0; i<size; i++ )
temp2[i]=initialValue[i]+(x/2.0)*temp[i];
// Nyt voimme soveltaa keskipistemenetelmää
// Kysy paljonko on f'(x/2), kun f(x/2) tiedetään
derive( temp, temp2, x/2.0, userData );
// Laske paljonko on f(0)+x*f'(x/2)
for ( i=0; i<size; i++ )
result[i]=initialValue[i]+x*temp[i];
}
4.3 Runge-Kutta-menelmä
Viimeisenä on vuorossa Runge-Kutta-menetelmä. Se on huomattavasti edellisiä menetelmiä monimutkaisempi ja siksi en ala sitä selittämään vaan annan ainoastaan sen laskevan koodin. Mainittakoon kuitenkin, että ideana on laskea arvo neljällä eri tavalla ja käyttää sitten näiden arvojen painotettua keskiarvoa. Huomaa, että "temp" tarvitsee 6 kertaa enemmän alkioita kuin on syötteessä.
void integrate( float *result, const float *initialValue,
DWORD size, float x,
void (*derive)(float *result,
const float *value,
float x, void *userData),
float *temp, void *userData )
{
int i;
float *temp2, *k1, *k2, *k3, *k4;
temp2=temp+size;
k1=temp+2*size;
k2=temp+3*size;
k3=temp+4*size;
k4=temp+5*size;
derive( temp, initialValue, 0, userData );
for ( i=0; i<size; i++ )
k1[i]=x*temp[i];
for ( i=0; i<size; i++ )
temp2[i]=initialValue[i]+0.5*k1[i];
derive( temp, temp2, 0.5*x, userData );
for ( i=0; i<size; i++ )
k2[i]=x*temp[i];
for ( i=0; i<size; i++ )
temp2[i]=initialValue[i]+0.5*k2[i];
derive( temp, temp2, 0.5*x, userData );
for ( i=0; i<size; i++ )
k3[i]=x*temp[i];
for ( i=0; i<size; i++ )
temp2[i]=initialValue[i]+k3[i];
derive( temp, temp2, x, userData );
for ( i=0; i<size; i++ )
k4[i]=x*temp[i];
for (i=0; i<size; i++)
result[i]=initialValue[i]+
(1.0/6.0)*k1[i]+
(1.0/3.0)*k2[i]+
(1.0/3.0)*k3[i]+
(1.0/6.0)*k4[i];
}
4.4 Integraattorin käyttö
Meillä on nyt valmis integraattori, mutta kuinka sitä oikein käytetään? Otetaan esimerkiksi funktio f(x)=x^2+3. Tämän funktion derivaatta on f’(x)=2x. Oletetaan nyt, että emme tiedä mitä funktio f(x) on. Tiedämme ainoastaan sen derivaatan ja alkuarvon f(0)=3. Haluamme tietää mitä on f(0.5). Jos alkuperäinen funktio olisi tiedossa, voisimme laskea f(0.5)=0.5^2+3=3.25. Mutta jos emme tiedä alkuperäistä funktiota, tarvitsemme integraattoria.
Luodaan ensin funktio ”derive”, joka antaa integraattorille funktion derivaatan.
void derive( float *result,
const float value,
float x, void *userData )
{
result[0]=2*x; // Funktion x^2+3 derivaatta on 2x
}
Ja siinä se on. Tässä tapauksessa derivaatan laskentaan tarvittiin vain x. Ei funktio arvoa ”value” tai käyttäjän antamaa ylimääräistä dataa ”userData”. Kutsutaan seuraavaksi integraattoria.
float initialValue=3;
float output;
float temp[6];
integrate(&output, &initialValue, 1, 0.5, derive, temp,
NULL);
Lopputuloksena muuttujan ”output” pitäisi sisältää 3.25, tai ainakin luku, joka on hyvin lähellä sitä. Vastauksen tarkkuus riippuu käytetystä menetelmästä.
Tehdäänpä pieni testi. Valitaan kolme satunnaista funktiota. Vaikka 2x+3, x^2+5x+2 ja 3*x^3+4*x^2+8*x+7. Kerron seuraavassa näiden funktioiden derivaatat perustelematta sen kummemmin miten ne lasketaan.
Funktio Derivaatta Alkuarvo f(0) 2x+3 2 3 x^2+5x+2 2x+5 2 3*x^3+4*x^2+8*x+7 9x^2+8x+8 7
Syötetäänpä nyt nämä derivaatat ja alkuarvot integraattoreillemme ja katsotaan mitä ne palauttavat seuraavilla x:n arvoilla: 0, 0.25, 0.5, 0.75 ja 1. Otetaan ensiksi f(x) =2x+3.
x: 0 0.25 0.5 0.75 1 Oikea tulos 3 3.5 4 4.5 5 Eulerin menetelmä 3 3.5 4 4.5 5 Keskipistemenetelmä 3 3.5 4 4.5 5 Runge-Kutta 3 3.5 4 4.5 5
Kaikki kolme menetelmää antoivat täysin oikeat vastaukset. Kokeillaanpa seuraavaksi funktiolla f(x)= x^2+5x+2.
x: 0 0.25 0.5 0.75 1 Oikea tulos 2 3.31 4.75 6.31 8 Eulerin menetelmä 2 3.25 4.5 5.75 7 Keskipistemenetelmä 2 3.31 4.75 6.31 8 Runge-Kutta 2 3.31 4.75 6.31 8
Keskipiste- ja Runge-Kutta-menetelmä antavat vielä aivan oikeat vastaukset, mutta Eulerin menetelmä heittää hieman. Lopuksi vielä kaikkein monimutkaisin tapaus f(x)= 3*x^3+4*x^2+8*x+7.
x: 0 0.25 0.5 0.75 1 Oikea tulos 7 9.3 12.38 16.52 22 Eulerin menetelmä 7 9 11 13 15 Keskipistemenetelmä 7 9.29 12.28 16.20 21.25 Runge-Kutta 7 9.3 12.38 16.52 22
Pahalta näyttää. Euler ampuu ihan metsään ja keskipistemenetelmäkin heittää hieman. Sen sijaan Runge-Kutta selviää tästäkin kunnialla. Jos otettaisiin vieläkin monimutkaisempi funktio, myös Runge-Kutta alkaisi menettää tarkkuutensa. Toinen huomattava asia on, että pienillä x:n arvoilla saatiin tarkempi tulos kuin suurilla.
Tulokset eivät siis olleet aivan oikein, mutta ne ovat sinne päin. Huonoiten toimi Eulerin menetelmä, sitten keskipistemenetelmä ja paras oli Runge-Kutta. Myös menetelmien monimutkaisuus, ja näin olen tarvittava laskuteho, nousi samassa järjestyksessä.
Ps. Jos kiinnostaa, niin tässä olisi valmis ohjelma, joka ajaa äskeiset esimerkkitapaukset.
http://www.suomipelit.com/files/artikkel...
5. Tasainen liike #
Kuten olet ehkä huomannutkin, tämän artikkelin ja tämän luvun otsikossa esiintyy sana liike. Liike tarkoittaa sitä, että kappaleet eivät välttämättä pysy paikallaan vaan niiden sijainti muuttuu ajan suhteen. Siis niiden sijainti MUUTTUU ajan SUHTEEN. Tässä vaiheessa minä vähän elättelin varovaista toivetta, että lukija olisi kokenut seuraavanlaisen ahaa-elämyksen: "Hetkinen! Tässähän jokin (sijainti) muuttuu suhteessa johonkin (aika). Sehän on sama asia kuin derivaatta!" Sijainnilla on siis derivaatta suhteessa aikaan. Sijainnin aikaderivaatta on sen verran yleinen ilmiö, että sillä on arkikielessä ihan oma sana: nopeus (englanniksi velocity). Autossa ei siis sanota olevan sijainnin aikaderivaattamittaria vaan nopeusmittari. Nopeutta merkitään yleensä symbolilla v ja sen yksikkö on metriä sekunnissa (lyhenne m/s). Jos esitämme sijaintia symbolilla s ja aikaa symbolilla t voimme esittää lausetta "sijainnin aikaderivaatta on nopeus" kaavalla s'(t)=v. Artikkelisarjan tässä osassa oletamme liikkeen olevan tasaista eli nopeus pysyy kokoajan vakiona. 2D-avaruudssa nopeus on kaksi komponenttinen vektori, jonka suunta esittää liikkeen suunnan ja pituus vauhtia. Vastaavasti 3D-avaruudessa käytämme kolme komponenttista vektoria. Seuraavassa nopeudella täydennetyt rakenteet.
typedef struct
{
int shape; // Muoto. 0=ympyrä ja 1=suorakulmio
float radius[2]; // ”Säde” metreinä.
float position[2]; // Sijainti metreinä
float orientation; // Asento radiaaneina
float velocity[2]; // Nopeus metreinä sekunnissa
} RIGIDBODY2D;
typedef struct
{
int shape; // Muoto. 0=pallo ja 1=laatikko
float radius[3]; // ”Säde” metreinä.
float position[3]; // Sijainti metreinä
float orientation[4]; // Asento kvaterniona
float velocity[3]; // Nopeus metreinä sekunnissa
} RIGIDBODY3D;
Jos tiedämme kappaleen nopeuden (eli sijainnin derivaatan ajan suhteen) haluamme varmaan laskea kappaleen uuden paikan sitä mukaan kun aika kuluu, jotta näkisimme kappaleen liikkuvan. Meillä on siis tiedossa nopeus (sijainnin derivaatta) ja haluamme laskea siitä sijainnin (se jonka derivaatta nopeus on). Kaavoilla ilmaistuna meillä on siis tiedossa s’(t)=v ja haluamme tietää mikä on s(t). Haiskahtaa pahasti differentiaaliyhtälöltä. Onpa onni, että meillä sattuu olemaan valmis väline sellaisen ratkaisemiseen: numeerinen integraattori.
Mietitäänpä hetki, kuinka laskisimme uuden sijainnin integraattorilla. Tarvitsemme sijainnin derivaatan ja alkuarvon. Derivaatta on nopeus ja alkuarvo on tämän hetkinen sijainti, eli sijainti hetkellä 0 ( s(0) ). Nopeus ja sijainti ovat kuitenkin vektoreita. Eipä hätää. Integraattorimme osaa ottaa parametrinaan taulukon, johon voimme nämä vektorit kopioida. Aika, jona uusi sijainti halutaan tietää, annetaan x:n arvona. Tehdäänpä seuraavaksi funktio ”simulate2D”, joka ottaa parametrinaan 2D-kappaleen ja simuloi sen liiketta ”t” sekuntia eteenpäin.
void simulate2D(RIGIDBODY2D *b, float t)
{
static float initialValue[2];
static float output[2];
static float temp[6*2];
// Kopioi nykyinen sijainti alkuarvoksi.
initialValue[0]=b->position[0];
initialValue[1]=b->position[1];
// Interoi aika-askeleen verran. Koska ”derive” tarvitsee
// Kappaleen nopeuden, Annamme osoittimen kappaleeseen
// ”userData”-parametrina.
integrate(output, initialValue, 2, t, derive,
temp, (void *)b);
// Kopioi tulos uudeksi sijainniksi
b->position[0]=output[0];
b->position[1]=output[1];
}
3D-avaruudssa funktio olisi muuten sama, mutta yksi komponentti enemmän. Vielä pitää määritellä funktio ”derive”, jotta integraattori toimisi.
void derive( float *result,
const float *value,
float x, void *userData )
{
// Osoitin kappaleeseen saatiin “userData”:n kautta
RIGIDBODY2D *b=(RIGIDBODY2D *)userData;
// Sijainnin derivaatta on nopeus.
result[0]=b->velocity[0];
result[1]=b->velocity[1];
}
Sitten pääsemmekin seuraavaan mielenkiintoiseen asiaan: tasaiseen pyörimisliikkeeseen. Otetaan ensiksi helpompi 2D tapaus. Asennon derivaattaa ajan suhteen kutsutaan kulmanopeudeksi (englanniksi angular velocity). Kulmanopeuden symboli on ω ja yksikkö radiaania sekunnissa (lyhenne rad/s). Jos merkitsemme asentoa symbolilla φ voimme kirjoittaa tämän kaavana φ’(t)= ω. Täydennetäänpä RIGIDBODY2D-rakenne kulmanopeudella.
typedef struct
{
int shape; // Muoto. 0=ympyrä ja 1=suorakulmio
float radius[2]; // ”Säde” metreinä.
float position[2]; // Sijainti metreinä
float orientation; // Asento radiaaneina
float velocity[2]; // Nopeus metreinä sekunnissa
float angularVelocity; // Kulmanopeus radiaaneina sekunnissa
} RIGIDBODY2D;
Uuden asennon laskeminen hoituu integraattorilla samoin, kuin sijainninkin. Lisätään vain kulmanopeus alkuarvo taulukoon sijainnin perään.
void simulate2D(RIGIDBODY2D *b, float t)
{
static float initialValue[3];
static float output[3];
static float temp[6*3];
// Kopioi nykyinen sijainti ja asento alkuarvoksi.
initialValue[0]=b->position[0];
initialValue[1]=b->position[1];
initialValue[2]=b->orientation;
// Interoi aika-askeleen verran. Koska ”derive” tarvitsee
// kappaleen nopeuden, Annamme osoittimen kappaleeseen
// ”userData”-parametrina.
integrate(output, initialValue, 3, t, derive,
temp, (void *)b);
// Kopioi tulos uudeksi sijainniksi ja asennoksi
b->position[0]=output[0];
b->position[1]=output[1];
b->orientation=output[2];
}
void derive( float *result,
const float *value,
float x, void *userData )
{
// Osoitin kappaleeseen saatiin “userData”:n kautta
RIGIDBODY2D *b=(RIGIDBODY2D *)userData;
// Sijainnin derivaatta on nopeus.
result[0]=b->velocity[0];
result[1]=b->velocity[1];
// Asennon derivaatta on kulmanopeus
result[2]=b->angularVelocity;
}
2D-tapaus siis oli helppo. Entäpä 3D-tapaus? Myös 3D-avaruudessa asennon derivaatta on kulmanopeus. 3D-avaruudessa pyöriminen kuitenkin tapahtuu akselin ympäri. Niimpä kulmanopeus on 3-komponenttinen vektori, jonka suunta esittää pyörimisakselia ja pituus vauhtia. Täydennetäämpä RIGIDBODY3D-rakenne kulmanopeudella.
typedef struct
{
int shape; // Muoto. 0=pallo ja 1=laatikko
float radius[3]; // ”Säde” metreinä.
float position[3]; // Sijainti metreinä
float orientation[4]; // Asento kvaterniona
float velocity[3]; // Nopeus metreinä sekunnissa
float angularVelocity[3]; // Kulmanopeus radiaaneina sekunnissa
} RIGIDBODY3D;
Integrointi tuottaa pienen ongelman. Asento voidaan kyllä kopioida sijainnin perään alkuarvoksi 2D-tapauksen tapaan seuraavasti.
void simulate3D(RIGIDBODY3D *b, float t)
{
static float initialValue[7];
static float output[7];
static float temp[6*7];
// Kopioi nykyinen sijainti ja asento alkuarvoksi.
initialValue[0]=b->position[0];
initialValue[1]=b->position[1];
initialValue[2]=b->position[2];
initialValue[3]=b->orientation[0];
initialValue[4]=b->orientation[1];
initialValue[5]=b->orientation[2];
initialValue[6]=b->orientation[3];
// Integroi aika-askeleen verran. Koska”derive” tarvitsee
// Kappaleen nopeuden, Annamme osoittimen kappaleeseen
// ”userData”-parametrina.
integrate(output, initialValue, 7, t, derive,
temp, (void *)b);
// Kopioi tulos uudeksi sijainniksi ja asennoksi
b->position[0]=output[0];
b->position[1]=output[1];
b->position[2]=output[2];
b->orientation[0]=output[3];
b->orientation[1]=output[4];
b->orientation[2]=output[5];
b->orientation[3]=output[6];
normalizeQuaternion(b->orientation);
}
Ongelma tulee vastaan ”derive”-funktion laadinnassa. Alkuarvossa nimittäin oli 4 komponenttia, mutta derivaatassa 3. Kaiken lisäksi alkuarvo oli joku ihmeen kvaternio ja derivaatta on vektori. Eihän kulmanopeus voi nyt mitenkään olla asennon derivaatta! Ei niin. Tämä ongelma ratkeaa sillä, että tallensimme asennon kvaterniona emmekä kulmana ja akselina. Sen kummemmin asiaa perustelematta: asennon derivaatta on
φ’(t) = 0.5 * kvaternioksi(ω) * φ
Nyt voimme kirjoittaa funktion ”derive”.
void derive( float *result,
const float *value,
float x, void *userData )
{
static float tempQ[4];
// Osoitin kappaleeseen saatiin “userData”:n kautta
RIGIDBODY3D *b=(RIGIDBODY3D *)userData;
// Sijainnin derivaatta on nopeus.
result[0]=b->velocity[0];
result[1]=b->velocity[1];
result[2]=b->velocity[2];
// Asennon derivaatta on 0.5*kulmanopeus*asento
tempQ[0]=0;
tempQ[1]=0.5*b->angularVelocity[0];
tempQ[2]=0.5*b->angularVelocity[1];
tempQ[3]=0.5*b->angularVelocity[2];
multQuaternion(&result[3], tempQ, &value[3]);
}
Tässä olikin jotain mielenkiintoista. Luimme tarvittavan asennon "derive"-funktion parametrinä saamasta funktion arvosta seuraavasti.
multQuaternion(&result[3], tempQ, &value[3]);
Emme siis lukeneet sitä käsiteltävänä olleesta kappaleesta itsestään seuraavasti:
multQuaternion(&result[3], tempQ, b->orientation);
Miksi? Vastaus on siinä, että asento ei ole vakio vaan se muuttuu ajan suhteen. Integraattori kutsuu ”derive”-funktiota haluamallaan x:n arvolla ei siis välttämättä arvolla 0. Integraattori kuitenkin välittää itse funktion arvon kyseisessä pisteessä, joten voimme lukea asennon siitä. Tämä asento saattaa olla eri kuin kappaleen asento hetkellä 0. Nopeus ja kulmanopeus sen sijaan luettiin kappaleesta itsestään, sillä paitsi niitä ei edes annettu integraattorille alkuarvona ne ovat koko ajan vakiot eli samat hetkestä riippumatta.
6. Miksi se piti tehdä niin hankalasti? #
Tässä vaiheessa mieleen saattaa tulla, että miksi hitossa se piti tehdä noin hankalasti? Jos olet koskaan ohjelmoinut peliin jotain liikkuvaa, olet todennäköisesti käyttänyt kaavaa uusi_sijainti=vanha_sijainti+nopeus*aika. Olet ehkä jopa keksinyt tämän kaavan itse. Jos nyt vertaat tätä kaavaa Eulerin menetelmään f(x)=f(0)+f’(0)*x ja toteat sen tosiasian, että tässä tapauksessa f(x) on uusi_sijainti, f(0) on vanha_sijainti, f’(0) on nopeus ja x on aika, niin huomaat, että kyseessä ovat täsmälleen samat kaavat! Olet siis itse asiassa integroinut käyttäen Eulerin menetelmää, ilmeisesti jopa tietämättäsi. Tasaisen liikkeen tapaus on itse asiassa niin yksinkertainen, että Eulerin menetelmä paitsi antaa aivan oikean lopputuloksen, myös voidaan kirjoittaa helposti kaavaksi ilman varsinaista integraattoria. Artikkelisarjan seuraavissa osissa siirrymme monimutkaisempiin tilanteisiin, joissa Eulerin menetelmä alkaa antaa virheellisiä arvoja ja asiat on helpompi laskea käyttäen yleistä integraattoria.
7. Esimerkkiohjelma #
Esimerkkiohjelmamme ei ole päällepäin sangen kiintoisa: vain muutamia liikkuvia kappaleita. Ohjelman kiintoisin asia on pellin alla hyrräävä integraattorisysteemi.
Kuten jo johdannossa ilmoitin, en ota kantaa siihen kuinka kappaleita piirretään. Fysiikallinen simulaatio ilman visualisaatiota ei kuitenkaan olisi kovin kummoinen, joten tein avuksi tiedostot 2dpiirto.h ja 3dpiirto.h. Ne sisältävät muutamia OpenGL-rajapintaa käyttäviä funktioita grafiikan piirtämiseen, joten kääntääksesi ohjelmat sinun pitää linkittää myös kirjastojen opengl32.lib ja glu32.lib kanssa. Lisää OpenGL:stä löydät halutessasi artikkelisarjasta "OpenGL:n perusteet". Yhtä hyvin grafiikan piirtämiseen voitaisiin käyttää mitä tahansa muuta rajapintaa/grafiikkakirjastoa kuten SDL tai Direct3D.
2dpiirto.h sisältää seuraavat funktiot:
int luo2DIkkuna(char *otsikko)
-Luo 512*512 pikselin kokoisen ikkunan, jonka pinnalle voi piirtää 2D-kappaleita niin, että ikkunan ala vastaa noin 20x20 metrin aluetta origon sijaitessa ikkunan keskellä. Palauttaa 0 jos ikkunan luonti epäonnistui, muuten 1.
void piirraSuorakulmio(float x, float y, float a, float w, float h)
-Piirtää suorakulmion, jonka keskipiste on koordinaateissa (x, y), jonka asentokulma on a ja jonka leveyden puolikas on w ja korkeuden puolikas h.
void piirraYmpyra(float x, float y, float a, float r)
-Piirtää ympyrän, jonka keskipiste sijaitsee koordinaateissa (x, y), jonka asentokulma on a ja jonka säde on r.
void piirraViiva(float x1, float y1, float x2, float y2)
-Piirtää viivan pisteestä (x1, y1) pisteeseen (x2, y2).
3dpiirto.h sisältää seuraavat funktiot.
int luo3DIkkuna(char *otsikko)
-Luo 512*512 pikselin kokoisen ikkunan, jonka pinnalle voi piirtää 3D-kappaleita niin, että ikkunan ala vastaa noin 20x20x20 metrin aluetta origon sijaitessa ikkunan keskellä noin kymmenen metrin syvyydellä ikkunan pinnasta. Palauttaa 0 jos ikkunan luonti epäonnistui, muuten 1.
void piirraLaatikko(const float p[3], const float q[4], float w, float h, float d)
-Piirtää laatikon, jonka keskipiste on pisteessä p, jonka asentokvaternio on q ja jonka leveyden puolikas on w, korkeuden puolikas h ja syvyyden puolikas d.
void piirraPallo(const float p[3], const float q[4], float r)
-Piirtää pallon, jonka keskipiste on pisteessä p, jonka asentokvaternio on q ja jonka säde on r.
void piirraViiva(float x1, float y1, float z1, float x2, float y2, float z2)
-Piirtää viivan pisteestä (x1, y1, z1) pisteeseen (x2, y2, z2).
2dpiirto.h:n ja 3dpiirto.h:n funktioita ei ole tarkoitettu käytettäväksi sekaisin samassa ohjelmassa. Lisäksi kummatkin tiedostot sisältävät seuraavan funktion.
void loputonSilmukka(void (*callback)(float time))
-Kutsuu parametrinään saamaansa funktiota toistuvasti peräkkäin, kunnes käyttäjä sammuttaa ohjelman sulkemispainikkeesta. Kutsuttava funktio saa parametriinään ajan sekunneissa siitä kuinka kauan sitten tätä funktiota kutsuttiin viimeksi.Tämän avulla simulaation voi ajastaa. Ikkuna on tyhjennetty harmaaksi aina ennen callback funktion kutsua.
2D-esimerkkiohjelman käännetyn version ja lähdekoodin voit ladata tästä: http://www.suomipelit.com/files/artikkel...
3D-esimerkkiohjelman käännetyn version ja lähdekoodin voit ladata tästä: http://www.suomipelit.com/files/artikkel...
8. Loppusanat #
Tässä osassa opit kuinka kappaleen asentoa voidaan esittää 2D- ja 3D-avaruudessa. Opit lisäksi numeerisesta integroinnista ja kuinka sitä sovelletaan tasaisen liikkeen laskemiseen. Seuraavassa osassa puhumme tilanteesta, jossa nopeus ei enää ole vakio eli puhumme kiihtyvyydestä ja voimista.
Raportoithan kaikki tästä artikkelista löytämäsi virheet (niin kirjoitus-, kuin asiavirheetkin) osoitteeseen markus.ilmola@pp.inet.fi , niin korjaan ne mahdollisimman nopeasti. Myös kaikki kommentit ja kysymykset ovat tervetulleita.
Lopuksi vielä linkkejä, joista löydät lisälukemista tässä artikkelissa opituista asioista. Varoitettakoon kuitenkin, että ne saattavat sisältää vaikeasti luettavia matemaattisia merkintöjä.
Katkelma kirjasta "Numerical Recipes in C: The Art of Scientific Computing":
http://www.library.cornell.edu/nr/bookcp...
David Barffin fysiikka artikkelisarjan liite:
http://www-2.cs.cmu.edu/~baraff/sigcours...

=2x.gif)
=x^2.gif)



