Minule jsme nakousli téma generování krajiny a dnes ho dokončíme. Čekají nás tyto čtyři příklady zakuklené do třech částí:
3.3 – Midpoint 2 | Krajina generovaná midpoint algoritmem s přidanými normálami a filtrováním povrchu |
3.4 – Fault Formation 1 | Metoda zlomů |
3.5 a 3.6 – Midpoint 3 a Fault Formation 2 |
Finální verze s vodní hladinou i adaptivní tesselací |
Nejprve doplníme nás midpointovský příklad z minulého dílu a vytvoříme kvalitní krajinu. Poté si představíme fault formation algoritmus a porovnáme výsledky obou metod. Ve finiši dotáhneme obě metody k „dokonalosti“ tím, že přidáme ještě několik vylepšení.
Pokud byste se divili, odkud se v příkladech na platformě Windows vzala konzole pro výpis chybových hlášek, tak za to může následující pragma z Main-Windows.cpp:
#pragma comment(linker,
"/subsystem:\"console\"")
Na Windows totiž jinak stdout a stderr mizí v neznámu, dokonce i když program spustíte z příkazového řádku.
Příklad 3.3: Midpoint 2
Midpoint algoritmus jsme si představili minule. Algoritmus pracoval skvěle, byli jsme však šokováni velmi špatným vizuálním výsledkem. Jak bylo nastíněno minule, důvodem byla absence normál a filtrování povrchu. Obojí i s dalšími vylepšeními si uvedeme nyní.
Předem bych chtěl upozornit, že pokud budete porovnávat kód Midpoint 2 a Midpoint 3, tak první z nich používá SoTriangleStripSet a druhý SoIndexedTriangleStripSet. Odtud tedy plynou trochu rozdílné některé části kódu.
Normály uložíme do grafu scény s pomocí nódu SoNormal:
SoNormal *normals = new SoNormal;
normals->vector.setNum(SbSqr(dimension-1) * 8);
SbVec3f *n = normals->vector.startEditing();
Pro výpočet normál používáme funkci calculateNormal. Ta pro dané souřadnice výškové mapy spočítá normálu. Nebudeme si ji zde uvádět, ale zájemci ji najdou ve zdrojových souborech.
Samotné normály nastavujeme podobně jako souřadnice vertexů:
c[ci] = SbVec3f(float(xi)*sx,
hmap[getHmapIndex(xi,yi)], float(yi)*sy);
n[ci++] = calculateNormal(xi, yi, sx, sy);
Tím jsou normály téměř odbyty, zbývá pouze záhadný nód SoNormalBinding:
// Pro normály nastavujeme binding,
// tj. zda-li specifikujeme normály
// pro každý bod (PER_VERTEX),
// nebo jen pro trojúhelníky (PER_FACE),
// či celá plocha má jen jednu normálu (OVERALL).
SoNormalBinding *nbind = new SoNormalBinding;
nbind->value.setValue(SoNormalBinding::PER_VERTEX);
root->addChild(nbind);
Binding znamená, jakým způsobem budeme normály specifikovat. Problematiku bindingu si necháme do příkladu Midpoint 3, zde se spokojíme s konstatováním, že PER_VERTEX binding znamená specifikovat normálu pro každý vertex. Jinak bychom mohli nastavit například hodnotu PER_FACE, což by znamenalo, že normálu určujeme vždy pro celý trojúhelník.
Druhým vylepšením, které zavádí náš příklad, je filtrování povrchu. To zařizuje funkce TerrainPatch::filter(float q), která projede celý terén v x-ovém i y-ovém směru a vyhladí ho podle parametru q. Například při průjezdu zleva do prava pracuje podle primitivní rovnice:
hmap[x,y] = hmap[x,y]*q + hmap[x-1,y]*(1-q)
tedy velmi primitivní filtrování, které však působí jak kouzelná hůlka na výslednou krajinu.
A abychom si mohli zkoušet měnit různé parametry krajiny a najít optimální nastavení pro nejlepší vizuální vzhled, nebo například měnit parametry generované krajiny, obsahuje kód parser command-line argumentů, kterými můžeme měnit nastavení i tohoto filtru. Pokud přidáme parametr -filterQ 1, filtrování nebude mít žádný efekt. Zde je kompletní výpis parametů:
g-h, –helpg | nápověda |
g-seed intg | gnastavení generátoru náhodných číselg |
g-dimension intg | grozměr či rozlišení výškové mapyg |
g-size floatg | gvelikost krajinyg |
g-heightQ floatg | gvýška krajinyg |
g-filterQ floatg | gparametr filtrovací funkceg |
g-hq intg | ghodnota 0 a 1 zapínají a vypínají dodatečnou tesselaci (zdvojnásobí počet trojúhelníků)g |
Parametry musíme zadávat v pořadí, jak je vidíme v seznamu, protože jejich parser je příliš hloupý. Skrze parametry můžeme například zkusit zvětšit výšku krajiny. Nebo můžeme změnit její rozlišení a zkusit si tak, kolik trojúhelníků utáhne naše grafická karta. Co se týče parametru -hq, bude rozebrán u Midpoint3, kde dojde ještě k dalším vylepšením.
Zdrojáky jsou ke stažení zde. A výsledný screenshot:
Tím prozatím opustíme midpoint, abychom se dostali k metodě fault formation.
Příklad 3.4: Fault Formation 1
Kód tohoto příkladu vyvinul Martin Havlíček z FIT, který se momentálně zabývá možnostmi jeho rozšíření na letecký simulátor nad nekonečnou krajinou. Kód byl nepatrně upraven mnou pro potřeby tohoto tutoriálu. Stáhnout si jej můžete zde.
Fault formation algoritmus používá velmi jednoduchý princip – náhodně vygeneruje „zlom“ v krajině a pak ke všem bodům jedné poloviny připočte nějakou hodnotu. Na následujícím obrázku vidíme výsledek po osmi takových zlomech.
My vyrobíme těchto zlomů zhruba 150, přičemž postupně snižujeme hodnotu, kterou připočítáváme ke krajině na jedné straně zlomu. Kdo chce vidět, jak vypadá krajina v tomto okamžiku, ať pustí náš příklad s parametrem -filterQ 1, čímž se eliminuje efekt závěrečného filtrování, které učiní krajinu úplně hladkou.
Celkově se dá říct, že fault formation generuje krajinu trochu jiného rázu, já bych to označil jako „kopcovitou“, kdežto midpoint krajinu bych nazval spíše slovem „hornatou“, ale posouzení je na vás. Zde je screenshot:
Většina kódu je shodná s naším midpointem, a my tedy soustředíme naši pozornost především na algoritmus. Ten začíná vygenerováním vektoru zlomu:
for (unsigned int iter = 0; iter < NumIter; iter++)
{
Rand1X = rand() % HMapWidth;
Rand1Y = rand() % HMapHeight;
Rand2X = rand() % HMapWidth;
Rand2Y = rand() % HMapHeight;
// vektor zlomu
FaultVectorX = Rand2X - Rand1X;
FaultVectorY = Rand2Y - Rand1Y;
Po té si spočítáme Deltu, která se s přibývajícími iteracemi snižuje, a pak už jen cyklíme přes celou výškovou mapu a body, které jsou na té správné straně zlomu jsou posunuty o Delta nahodu.
// hodnota, ktera bude pridana
// na jednu stranu od zlomu
Delta = (float(MaxDelta - ((MaxDelta-
MinDelta) * iter))) / NumIter;
for (unsigned int x = 0; x < HMapWidth; x++)
for (unsigned int y = 0; y < HMapHeight; y++)
{
// vektor k aktualni pozici na mape
ToPointVectorX = x - Rand1X;
ToPointVectorY = y - Rand1Y;
// z-ova slozka vektoroveho soucinu urci,
// na ktere strane od "zlomu"
// se aktualni bod nachazi
if ((ToPointVectorX*FaultVectorY-
FaultVectorX*ToPointVectorY) > 0)
HMap[getHmapIndex(x,y)] += Delta;
}
}
Na závěr už jen přefiltrujeme celou krajinu:
// aplikuje se filtr
for (int i=0; i<10; i++)
filter(FilterValue);
Možná by se dalo říci více, ale nechme si to do dalšího příkladu, ve kterém krajinu ještě více vylepšíme.
Příklad 3.5 a 3.6: Midpoint 3 a Fault Formation 2
Díky podobnosti obou příkladů je není třeba rozebírat zvlášť, takže to provedeme naráz. Zdrojáky si můžeme stahnout pro midpoint i fault formation. Kód na následujících řádcích však bude z fault formation.
Při pohledu na screenshot není třeba dlouho hledat, co přibylo. Je to vodní hladina a obarvení terénu. To, co už tolik není vidět, je adaptivní dělení trojúhelníků, které se ovládá parametrem -hq.
Vytvoření vodní hladiny je vyřešeno pouhým čtvercem modré barvy o určité výšce, který je vytvořen jako dva trojúhelníky uložené v SoTriangleStripSet. Více bychom našli ve zdrojácích. My se zde soustředíme především na obarvení krajiny.
Barva krajiny je řízena funkcí int getMaterialIndex(height, minHeight, maxHeight), která nám vrací podle výšky terénu číslo 0 pro travnaté zelené oblasti, 1 pro horské oblasti a 2 pro samotné zasněžené vrcholky hor.
Avšak první věc, kterou musíme udělat, je připravit SoMaterial, aby obsahoval požadované barvy:
// definice materialu (barvy) krajiny
SoMaterial *material = new SoMaterial;
// trava
material->ambientColor.set1Value(
0, SbColor(0.9f, 0.9f, 0.9f));
material->diffuseColor.set1Value(
0, SbColor(0.0f, 0.6f, 0.0f));
// skala
material->ambientColor.set1Value(
1, SbColor(0.9f, 0.9f, 0.9f));
material->diffuseColor.set1Value(
1, SbColor(0.6f, 0.3f, 0.0f));
// snih
material->ambientColor.set1Value(
2, SbColor(0.9f, 0.9f, 0.9f));
material->diffuseColor.set1Value(
2, SbColor(0.6f, 0.6f, 0.6f));
root->addChild(material);
Pro vlastní zobrazování pak používáme SoIndexedTriangleStripSet, neboť nám ušetří mnoho paměti. V prvé řadě na vertexy se můžeme odkazovat pouze indexem, a není tedy nutno uvádět jeden vertex vícekrát v SoCoordinate3. Mnohem více ale získáme ve spojení s materiály. Jeden materiál, to je 14 floatů, a ty bychom museli specifikovat pro každý vertex, což je obrovská ztráta paměti. Proto nastavíme material binding na PER_VERTEX_INDEXED:
// binding pro materiály
SoMaterialBinding *mbind = new SoMaterialBinding;
mbind->value.setValue(
SoMaterialBinding::PER_VERTEX_INDEXED);
root->addChild(mbind);
Od této chvíle se bude SoIndexedTriangleStripSet odkazovat pro každý rendrovaný vertex do svého fieldu materialIndex a vybere odpovídající materiál z našeho nódu SoMaterial.
Aby nezůstal problém bindingu rozebrán pouze z půlky, v krátkosti se mrkneme na celou problematiku. Možná si někteří vzpomínáte, že na tento problém jsme v tomto díle už narazili ve spojitosti s normálami, což je ale totéž v bledě modrém.
Materiály můžeme nanášet sedmi způsoby. Máme tedy sedm hodnot pro binding:
- OVERALL – jediný materiál pro celý objekt
- PER_PART – pro každý triangle strip je použit jeden materiál
- PER_FACE – každý trojúhelník má svůj materiál
- PER_VERTEX – pro každý vrchol trojúhelníku specifikujeme materiál
A indexovací verze hodnot:
- PER_PART_INDEXED
- PER_FACE_INDEXED
- PER_VERTEX_INDEXED
OVERALL jsme například použili pro náš terén v době, kdy byl jednobarevný. Ani jsme jej nemuseli specifikovat, protože se používá implicitně.
Pro naše materiály zde budeme používat PER_VERTEX, a to ještě v indexovací verzi. Jaký je rozdíl mezi indexovacím a neindexovacím přístupem?
V neindexovacím bychom pro každý vertex museli mít jeden materiál v SoMateriál. Každý materiál je asi 14 floatů (56 byte), což je značná paměťová ztráta, udělat toto pro všechny vertexy našeho terénu. Naproti tomu v indexovacím režimu se SoIndexedTriangleStripSet vždy podívá do svého fieldu materialIndex a vezme jeden z našich třech materiálů, které jsme specifikovali pro naši krajinu.
Kromě bindingu materiálů máme i binding normál a texturovacích souřadnic, které pracují úplně stejně. Takže jsme zabili tři muchy jednou ranou.
Jedna věc zbyla na závěr. Možná jste si všimli, že občas jdou v krajině vidět hranice mezi trojúhelníky a krajina prostě není úplně hladká. Tento efekt roste s klesajícím rozlišením výškové mapy. Prostě jsem prováděl experimenty, jak udělat krajinu co nejhladší, a přitom nepotřeboval závratné množství trojúhelníků. V dnešní době málokterý akcelerátor utáhne 256×256, a to ještě nerendrujeme ani tanky, ani exploze, ani nic dalšího, co každá hra vyžaduje. Výsledkem je command-line parametr -hq, který se pak předává jako parametr hq do několika funkcí programu. A i vy si můžete vyzkoušet, jaký má vliv na výsledkou kvalitu. Zde je význam jednotliných hodnot:
- hq = 0 – standardní nastavení, dlaždice rozdělena do dvou trojúhelníků vždy ve stejném směru; pro 64×64 to znamená 8 000 trojúhelníků
- hq = 1 – dlaždice rozdělena do dvou trojúhelníků, přičemž směr rozdělení je ve směru větší změny výšky v dlaždici; pro 64×64 je to 8 000 trojúhelníků
- hq = 2 – dlaždice rozdělena na čtyři trojúhelníky; pro 64×64 je to 16 000 trojúhelníků
Můžete si zkusit a zjistit, která z nich vypadá nejlépe. Doporučuji i zapnout si pravým tlačítkem v menu wire-frame overlay, aby člověk viděl to dělení na trojúhelníky.
Závěr
Tím končí dnešní díl tutoriálu, a povede-li se, objeví se někdy v budoucnu první minihra, která by se dala nazvat UFO shooting.
Celý článek je sbalený k dispozici zde.