Dotychczas zajmowaliśmy się techniczną stroną silnika, mechaniką całego systemu. Przyszedł czas na poznanie sposobów poprawiania graficznej strony gry. Przyjrzymy się systemowi materiałów silnika renderującego Ogre, a także napiszemy podstawowe programy cieniujące (shadery).
[Część 1 ]
[Część 2 ]
[Część 3 ]
[Część 4 ]
[Część 5 ]
[Część 6 ]
[Część 7 ]
Materiały
Silnik Ogre posiada moduł renderujący, który możemy bardzo swobodnie konfigurować za pomocą specjalnych skryptów. Składnia, konstrukcja tych plików została inspirowana jest najprawdopodobniej formatem efektów DirectX (FX), lecz posiadają one znacznie szerszy zakres możliwości. Najważniejsze elementy materiału to:
- Techniki
- Przebiegi
- Programy cieniujące
- Jednostki teksturujące
Dzięki nim będziemy w stanie osiągnąć efekty jak na poniższym obrazku:
Techniki
Poprawny materiał musi zawierać co najmniej jedną technikę. Zapewne mieliście okazję oglądać pliki materiałów już wcześniej. Techniki mogą też posiadać nazwy:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| material MaterialTestowy
{
technique Domyslna
{
...
// fajne shadery
}
technique Awaryjna
{
...
// fixed-pipeline
}
} |
Pierwszym z zastosowań technik jest zapewnienie awaryjnych trybów renderowania grafiki. Silnik sprawdza, czy sprzęt na którym uruchamiana jest aplikacja posiada możliwości wystarczające do obsługi danej techniki. Jeżeli wybrana technika nie jest obsługiwana, silnik użyje następnej w kolejności.
Drugim ich zastosowaniem jest renderowanie z obliczeniami wykonywanymi na buforach danych z przestrzeni ekranu (
deferred shading ). W skrócie, idea polega na zapisywaniu do tekstury informacji takich jak wektory normalne, tekstury i wykonywaniu obliczeń w odniesieniu do tych danych. Wówczas tworzymy osobne techniki do renderowania różnych składowych.
Przebiegi
Przebieg to po prostu przetworzenie całej geometrii korzystającej z danego materiału. Skrypty dają nam możliwość renderowania wieloprzebiegowego i określenia kluczowej dla całej idei operacji łączenia kolejnych przebiegów.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| technique
{
pass Przebieg0
{
...
// renderujemy tekstury
}
pass Przebieg1
{
...
// renderujemy cienie
scene_blend modulate
}
} |
Dostępne operacje mieszania przebiegów (
scene_blend
,
blending modes) to:
add
- wartości kolorów pikseli przebiegu są dodawane do poprzednich. Operacja znajduje szerokie zastosowanie, np. w sumowaniu natężenia oświetlenia.
modulate
- mnożenie. Ta operacja może tylko ściemnić scenę, można użyć jej do aplikowania cieni za pomocą statycznych map oświetlenia (lightmaps).
colour_blend
- koloruje nowy przebieg na podstawie kolorów wejściowych, tworzy wrażenie przezroczystości.
alpha_blend
- używa wartości kanału alfa tekstury do określenia stopnia przezroczystości.
Wartości składowych kolorów są liczbami z przedziału [0,1] dla każdego kanału.
Istnieje też dwuargumentowy wariant
scene_blend
dający większą kontrolę nad sposobem łączenia przebiegów:
scene_blend src dst
Wówczas wynikowy kolor zapisany do bufora obliczany jest wg. formuły: wynik = (źródło * src) + (nowy_przebieg * dst). Poprawne wartości src i dst to:
one
- wartość 1 na wszystkich kanałach
zero
- wartość 0 na wszystkich kanałach
dest_colour
- kolor docelowego przebiegu
src_colour
- dotychczasowy kolor w buforze
one_minus_dest_colour
- 1 - dest_colour
one_minus_src_colour
- 1 - src_colour
dest_alpha
- wartość kanału alfa docelowego przebiegu
src_alpha
- wartość kanału alfa poprzednich przebiegów
one_minus_dest_alpha
- 1 - dest_alpha
one_minus_src_alpha
- 1 - src_alpha
Mieszanie w takiej samej formie jest dostępne w niskopoziomowych API graficznych, takich jak OpenGL i DirectX. Łatwo zauważyć, że parametry jednoargumentowej wersji
scene_blend
są odpowiednikami pewnych kombinacji argumentów wersji dwuargumentowej, np.
scene_blend add
to
scene_blend one one
,
ponieważ dla tego ustawienia
wynik = (żródło * 1) + (nowy_przebieg * 1) = źródło + nowy_przebieg.
Samą operację dodawania można zastąpić inną za pomocą parametru
scene_blend_op
(
manual ).
Przebiegi mogą być powtarzane. Liczba iteracji przebiegu może być z góry ustalona, lub też zależeć np. od liczby świateł na scenie. Do określania liczby iteracji służy parametr
iteration
, przykładowo:
1
2
3
4
5
6
7
8
9
10
| technique
{
pass
{
iteration once_per_light
scene_blend add
...
}
} |
wyrenderuje geometrię tyle razy, ile świateł znajduje się na scenie sumując przebiegi, np:
Opis rozmaitych argumentów iteracji:
http://www.ogre3d.org/docs/manual/manual_16.html#SEC71 .
Programy cieniujące
Podstawą renderowania grafiki w dzisiejszych grach są programy cieniujące (shadery). Bez nich bylibyśmy skazani na
tzw. fixed pipeline, czyli zestaw prostych, zalutowanych w układzie graficznym operacji na geometrii i teksturach. Programy cieniujące pozwalają nam zaprogramować na karcie graficznej bardziej złożone obliczenia. Istnieje kilka rodzajów programów cieniujących:
- Vertex program - Program dostaje na wejściu pozycje wierzchołków w świecie, a zwraca pozycję w przestrzeni ekranu
- Fragment program - Program przetwarzający pojedynczy fragment (coś, co możemy nazwać kandydatem na wynikowy piksel). Dostaje na wejściu to, co zwraca vertex-program za wyjątkiem pozycji wierzchołka. Wartości przekazywane z vertex-programu będą dostarczone do fragment-programu już po interpolacji.
- Geometry program - Program posiadający możliwość tworzenia nowej geometrii (wierzchołków), która również jest poddawana działaniu programów.
Będziemy zajmować się jedynie pierwszymi dwoma typami programów.
Jaką postać ma taki program? Jest to po prostu funkcja, która przyjmuje pewne dane indywidualne dla każdego wierzchołka oraz posiada dostęp do pewnych danych globalnych. Do opisu tej funkcji służą języki programowania takie jak Cg, GLSL (OpenGL) i HLSL (DirectX). Wybierzemy język Cg rozwijany przez nVidię, ponieważ programów w nim napisanych będziemy mogli używać zarówno w trybie DirectX, jak i OpenGL. Ponadto język posiada składnię bardzo zbliżoną do składni HLSL, więc poznamy dwa języki za jednym razem.
Pierwszy program
Jako przykładem, posłużymy się najprostszym, klasycznym fragment-programem zwracającym zawsze kolor pomarańczowy na wyjściu. Na razie zadanie vertex-programu pozostawimy domyślnej implementacji.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| struct FragmentIn
{
float2 TexCoord : TEXCOORD0;
};
struct FragmentOut
{
float4 Color : COLOR;
};
FragmentOut fragment_func(FragmentIn In)
{
FragmentOut Out;
Out.Color.r = 1;
Out.Color.g = 0.7;
return Out;
} |
OgarneFP.cg
Przyjrzyjmy się... Składnia języka jest C-podobna. Program jest funkcją, która przyjmuje pewne dane, niekoniecznie musi to być jedna zmienna, więc dobrym zwyczajem jest opakowywanie danych wejściowych, a także wyjściowych programów w struktury.
W Cg mamy do dyspozycji bardzo wiele nowych typów podstawowych, takich jak
float2, float3, float4
, będących po prostu wektorami typu
float
o odpowiedniej długości. Do składowych można się odnosić poprzez pola
r, g, b, a
, lub też
x, y, z, w
. Istnieją również typy macierzowe
float3x3, float4x4, float4x3, ...
.
Dużą odmianą składniową w stosunku do C są tzw. semantyki -
TEXCOORD0, COLOR
. Ich głównym zadaniem jest usprawnienie wymiany danych między aplikacją i programem cieniującym. Twórca aplikacji lub silnika może odgórnie ustalić, jakie wejściowe będą zawierały określone semantyki. W przypadku Ogre dokumentacja dobrze opisuje używane semantyki. W przypadku powyższego programu oczekujemy, że pod semantyką
TEXCOORD0
zapisane będą współrzędne mapowania tekstury wysłane przez vertex-program (w tym przypadku zastąpiony przez fixed-pipeline, który tam właśnie zapisuje współrzędne mapowania tekstur).
Wykorzystanie
Spróbujmy zastosować ten prosty program w grze. W tym celu musimy go zapisać do pliku, najlepiej z rozszerzeniem .cg, np. OrangeFP.cg.
Oprócz tego, musimy poinformować silnik o tym programie. Robimy to poprzez skrypt z rozszerzeniem .program. Tworzymy więc plik o nazwie Programs.program i wypełniamy go deklaracją:
1
2
3
4
5
6
| fragment_program FragmentProgram/Orange cg
{
source OrangeFP.cg
entry_point fragment_func
profiles ps_1_1 arbfp1
} |
Programs.program
Nazwą programu rozpoznawaną w silniku Ogre jest
FragmentProgram/Orange
. Parametr
entry_point
określa nazwę funkcji głównej programu. W jednym pliku .cg może być zapisanych wiele programów. Parametr
profiles
ustala profile wymagane do kompilacji programu. W tym przypadku korzystamy z profilu Pixel Shader 1.1 dla DirectX i ARB Fragment Program 1 dla OpenGL. Lista wszystkich dostępnych profili i obsługujących je kart graficznych znajduje się pod adresem
http://www.ogre3d.org/docs/manual/manual_18.html . Kompilacja programu z wyższym profilem zmniejsza ograniczenia nałożone na nas podczas pisania programu cieniującego, lecz zwiększa wymagania sprzętowe. Zalecane jest używanie najniższego profilu niezbędnego do napisania danego programu cieniującego.
Aby zobaczyć wynik działania programu w grze, podmieńmy jeden z istniejących materiałów. Zastąpmy materiał wazy w pliku Vase.material następującym:
1
2
3
4
5
6
7
8
9
10
11
12
| material Vase/TEXFACE/vase.jpg
{
technique
{
pass
{
fragment_program_ref FragmentProgram/Orange
{
}
}
}
} |
Vase.material
Jak widać wykorzystanie fragment-programu jest bardzo proste, wystarczy podać jego nazwę jako parametr
fragment_program_ref
. Konieczne jest wstawień klamer, ponieważ składnia materiałów dopuszcza przekazywanie programom cieniującym dodatkowych parametrów, czym będziemy zajmować się za chwilę. Nazwa materiału
Vase/TEXFACE/vase.jpg
została wygenerowana przez Blendera podczas eksportowania modelu wazy.
Aby programy cieniujące w języku CG mogły zostać skompilowane, musimy dołączyć do programu odpowiedni plug-in:
Plugin_CgProgramManager
Skopiuj pliki z pobranego archiwum do katalogu roboczego gry, zastąp plik Plugins.cfg.
Program teksturujący
Drugi fragment-program jaki napiszemy będzie potrafił teksturować siatki:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| struct FragmentIn
{
float2 TexCoord : TEXCOORD0;
};
struct FragmentOut
{
float4 Color : COLOR;
};
FragmentOut fragment_func(FragmentIn In,
uniform sampler2D DiffuseMap : TEXUNIT0
)
{
FragmentOut Out;
Out.Color = tex2D(DiffuseMap, In.TexCoord);
return Out;
} |
JustTextureFP.cg
Jak widać, program konstrukcją przypomina poprzedni. Funkcja główna jednak zyskała drugi argument, jest nim zmienna oznaczona atrybutem
uniform. Tego typu zmienne są pochodzenia zewnętrznego, tj. są przekazywane przez silnik. Do semantyki
TEXUNIT0
przekazywana jest pierwsza użyta w przebiegu tekstura. Zwyczajne tekstury są w CG obsługiwane przez typ
sampler2D
. Ten program robi użytek z otrzymywanych współrzędnych mapowania tekstury - po prostu wydobywa odpowiedni teksel z tekstury za pomocą funkcji
tex2D()
.
Pierwszym argumentem funkcji
tex2D()
jest tekstura, z której odczytany ma być kolor, drugim zaś dwie współrzędne z przedziału [0,1].
Aby program zadziałał, musimy dostarczyć w przebiegu teksturę:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| material Vase/TEXFACE/vase.jpg
{
technique
{
pass
{
fragment_program_ref FragmentProgram/JustTexture
{
}
texture_unit
{
texture vase.jpg
}
}
}
} |
Vase.material
W bardzo łatwy sposób możemy dokonywać rozmaitych operacji na kolorach, np. zwiększyć kontrast tekstury dwukrotnie:
1
2
3
4
| FragmentOut Out;
float4 middle = float4(0.5);
Out.Color = middle + (tex2D(DiffuseMap, In.TexCoord) - middle) * 2;
return Out; |
JustTextureFP.cg
Vertex program
Dotychczas przetwarzaniem wierzchołków zajmował się fixed pipeline. Aby znalazły się one w naszej gestii, musimy użyć vertex programu:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| struct VertexIn
{
Position : POSITION;
TexCoord : TEXCOORD0;
};
struct VertexOut
{
Position : POSITION;
TexCoord : TEXCOORD0;
}
VertexOut vertex_func(VertexIn In,
uniform mat4x4 WorldViewProj
)
{
VertexOut Out;
Out.Position = mul(WorldViewProj, In.Position);
Out.TexCoord = In.TexCoord;
return Out;
} |
BasicVP.cg
Program cieniujący dla wierzchołków jest w istocie funkcją zwracającą pozycję wierzchołka w przestrzeni ekranu, zatem jego najprostszy wariant po prostu przepuszcza wejściową pozycję wierzchołka (jest to pozycja względem środka modelu) przez konkatenację macierzy świata (modelu), widoku i projekcji (przekształcenia modelu, kamery i rzutowanie na ekran).
Ponieważ przejęliśmy kontrolę nad przetwarzaniem wierzchołków, musimy sami przekazywać dane potrzebne fragment-programowi, w tym przypadku współrzędne mapowania tekstury. Program wierzchołków rzadko kiedy jest bardziej rozbudowany. Często jednak warto przerzucić do niego kosztowne obliczenia z fragment-programu, jeżeli tylko jest taka możliwość.
Większość zmiennych typu uniform przekazuje się poprzez pliki materiałów. W przypadku zmiennej WorldViewProj:
1
2
3
4
5
6
7
8
| ...
vertex_program_ref VertexProgram/SimpleLight
{
param_named_auto WorldViewProj worldviewproj_matrix
}
... |
materiał
Silnik Ogre sam zadba o to, by przekazywać do zmiennej WorldViewProj aktualną macierz.
Opis wszystkich zmiennych silnika dostępnych dla shaderów znajduje się pod adresem
http://www.ogre3d.org/docs/manual/manual_23.html#SEC125 .
Oświetlenie
Potrafimy już pisać programy cieniujące dla wierzchołków i fragmentów. Możemy więc rozpocząć implementację własnego oświetlenia. Na początku stworzymy bardzo proste shadery nieuwzględniające tekstur:
Wejście i wyjście vertex-programu:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| struct VertexIn
{
float4 Position : POSITION;
float3 Normal : NORMAL;
};
struct VertexOut
{
float4 Position : POSITION;
float3 LightDir : TEXCOORD1;
float3 Normal : TEXCOORD2;
}; |
SimpleLightVP.cg
Do obliczenia oświetlenia potrzebujemy wektora normalnego. Dostarcza nam go Ogre pod semantykę NORMAL. Pozycję światła przekażemy jako zmienną uniform. Chcemy, aby pozycja światła była podana w przestrzeni obiektu (takiej, w której obiekt ma współrzędne (0,0,0)), by nie trzeba było przekształcać jej dodatkowymi macierzami.
Do fragment-programu przesyłamy obliczony wektor kierunku światła (znormalizowany wektor kierunku od światła do wierzchołka), a także wektor normalny wierzchołka.
1
2
3
4
5
6
7
8
9
10
11
| VertexOut vertex_func(VertexIn In,
uniform mat4x4 WorldViewProj,
uniform float4 LightPosition
)
{
VertexOut Out;
Out.Position = mul(WorldViewProj, In.Position);
Out.LightDir = normalize(LightPosition.xyz - (In.Position * LightPosition.w));
Out.Normal = In.Normal;
return Out;
} |
SimpleLightVP.cg
Podobnie jak w poprzednim vertex-programie, przekształcamy pozycję wierzchołka za pomocą macierzy. Następnie obliczamy wektor światła dla tego wierzchołka. Korzystamy z dostępnej wbudowanej funkcji
normalize
. Zmienna
LightPosition
jest oznaczona jako uniform, jej wartość uaktualniać będzie silnik. Dla świateł punktowych jest to (x,y,z,1), gdzie wartości (x,y,z) są współrzędnymi światła, zaś dla świateł kierunkowych (-x,-y,-z,0), gdzie (x,y,z) jest wektorem kierunku światła. Kierunek padania światła kierunkowego jest niezależny od pozycji wierzchołka, toteż w tym przypadku pomijamy pozycję wierzchołka zerując ją mnożąc przez LightPosition.w. Przejdźmy do fragment-programu.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| struct FragmentIn
{
float2 LightDir : TEXCOORD1;
float3 Normal : TEXCOORD2;
};
struct FragmentOut
{
float4 Color : COLOR;
};
FragmentOut fragment_func(FragmentIn In)
{
FragmentOut Out;
float4 orange = float4(1,0.5,0,1);
Out.Color = orange * dot(In.Normal, In.LightDir);
return Out;
} |
SimpleLightFP.cg
Fragment-program jest prosty - oblicza natężenie oświetlenia fragmentu - iloczyn skalarany wektora normalnego i wektora światła.
Musimy pamiętać, by przekazać dane światła do zmiennej uniform w plikach materiału. Zaaplikujmy programy cieniujące na postaci:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| material Material
{
receive_shadows on
technique
{
pass
{
vertex_program_ref VertexProgram/SimpleLight
{
param_named_auto WorldViewProj worldviewproj_matrix
param_named_auto LightPosition light_position_object_space 0
}
fragment_program_ref FragmentProgram/SimpleLight
{
}
}
}
} |
Man.material
Liczba 0 przy zmiennej LightPosition oznacza numer światła. W tej prostej wersji obsługujemy tylko jedno światło, lecz gdy będziemy chcieli iterować po kilku światłach na przebieg, wtedy możemy pozycję każdego z nich zapisać do osobnej zmiennej. Indeks jest względny w zależności od parametrów iterowania.
Mapowanie wypukłości (Bump mapping)
Mapowanie wypukłości jest ogólną nazwą dla technik, które pozwalają odwzorować zniekształcenia geometrii nie ingerując w nią. Najprostszą z tych technik jest mapowanie wektorów normalnych (
normal mapping). W poprzedniej implementacji oświetlenia pobieraliśmy wektor normalny na wejściu vertex-programu. Każdy wierzchołek posiadał swój wektor normalny. W normal mappingu informację o wektorze normalnym pobieramy z dodatkowej tekstury. Wektor normalny jest zakodowany jako kolor - trzy składowe, r,g,b z wartościami w przedziale [0,1] przedstawiają wektor ze współrzędnymi z wartościami w przedziale [-1,1], przykładowo wektor (0,0,1) jest zakodowany jako kolor (0.5,0.5,1), czyli taki fiołkowy.
W przypadku, gdy tekstura będzie wykorzystana wielokrotnie, na różnych ścianach, nie da na niej odwzorować wektorów normalnych powierzchni obiektu w stosunku 1:1. Posiłkujemy się wtedy klasycznym wektorem normalnym z geometrii (tym, którego używaliśmy przy najprostszym oświetlaniu), a wektor zapisany w teksturze jest niezależny od powierzchni. Mamy wtedy do czynienia z tzw.
tangent space normal mapping. Ponieważ wektor normalny z tekstury jest niezależny, musimy przekształcić wektor kierunku światła do bazy przestrzeni stycznej do powierzchni wobec której chcemy, aby wektor był względny (czyli lokalnego zestawu trzech osi) . Bazę tą wyznaczają trzy ortogonalne wektory-osie: wektor normalny, wektor styczny i wektor binormalny. Wektor normalny dostajemy bezpośrednio od silnika. Wektor styczny wymaga obliczenia, przebiega on wzdłuż krawędzi mapowanej tekstury, na szczęście robi to za nas konwerter plików modeli. Wektor binormalny jest iloczynem wektorowym poprzednich dwóch wektorów.
Programy cieniujące
Zaczniemy od vertex-programu. Ustalmy wejście i wyjście:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| struct VertexIn
{
float4 Position : POSITION;
float2 TexCoord : TEXCOORD0;
float3 Normal : NORMAL;
float3 Tangent : TANGENT;
};
struct VertexOut
{
float4 Position : POSITION;
float2 TexCoord : TEXCOORD0;
float3 LightDir : TEXCOORD1;
}; |
SimpleBumpVP.cg
Program na wejściu przyjmuje dodatkowy atrybut wierzchołka - wektor styczny na semantyce TANGENT.
Na wyjściu nie przekazujemy już wektora normalnego, ponieważ będziemy go odczytywać we fragment-programie z tekstury.
LightDir
jest kierunkiem światła w przestrzeni stycznej do wierzchołka.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| VertexOut vertex_func(VertexIn In,
uniform mat4x4 WorldViewProj,
uniform float4 LightPosition
)
{
VertexOut Out;
Out.Position = mul(WorldViewProj, In.Position);
Out.TexCoord = In.TexCoord;
float3 lightDir = normalize(LightPosition.xyz - (In.Position * LightPosition.w));
float3 binormal = cross(In.Tangent, In.Normal);
float3x3 tbnMatrix = float3x3(In.Tangent, binormal, In.Normal);
Out.LightDir = mul(tbnMatrix, lightDir);
return Out;
} |
SimpleBumpVP.cg
Tym razem zapisujemy kierunek światła w przestrzeni obiektu do zmiennej lokalnej, by przekształcić go do przestrzeni stycznej. Macierz zmiany bazy tworzymy we wierszu 12 z odpowiednich wektorów.
Fragment-program
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| struct FragmentIn
{
float2 TexCoord : TEXCOORD0;
float3 LightDir : TEXCOORD1;
};
struct FragmentOut
{
float4 Color : COLOR;
};
FragmentOut fragment_func(FragmentIn In,
uniform sampler2D DiffuseMap : TEXUNIT0,
uniform sampler2D NormalMap : TEXUNIT1
)
{
FragmentOut Out;
float3 normal = (tex2D(NormalMap, In.TexCoord) - 0.5) * 2.0;
Out.Color = tex2D(DiffuseMap, In.TexCoord) * dot(normal, In.LightDir);
return Out;
} |
SimpleBumpFP.cg
W odróżnieniu od poprzedniego fragment-programu tym razem korzystamy z tekstury do określenia koloru, a także pobieramy wektor normalny z drugiej tekstury (
NormalMap
). Tekstury z mapą wektorów normalnych oczekujemy na drugiej jednostce teksturującej (TEXUNIT1).
Zastosowanie
Aby przetestować działanie nowego shadera musimy posiadać model z wygenerowanymi wektorami stycznymi oraz tekstur:
Nowy model i shadery
W materiale musimy uwzględnić mapę wektorów normalnych:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| material Man/TEXFACE/Armour.png
{
receive_shadows on
technique
{
pass
{
vertex_program_ref VertexProgram/SimpleBump
{
param_named_auto WorldViewProj worldviewproj_matrix
param_named_auto LightPosition light_position_object_space 0
}
fragment_program_ref FragmentProgram/SimpleBump
{
}
texture_unit
{
texture Armour.png
}
texture_unit
{
texture ArmourBump.png
}
}
}
} |
Man.material
Od teraz na siatce postaci powinna jawić się tekstura, a także dodatkowe detale:
Jak skrócić pliki materiałów?
Nie trudno zauważyć, że pliki materiałów szybko się rozrastają wraz ze stopniem skomplikowania użytych programów. Ogre obsługuje jednak dziedziczenie materiałów. Aby nie powielać kodu powyższego skryptu na wielu materiałach, wystarczy utworzyć szablonowy materiał:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| material Template/SimpleBump
{
receive_shadows on
technique
{
pass
{
vertex_program_ref VertexProgram/SimpleBump
{
param_named_auto WorldViewProj worldviewproj_matrix
param_named_auto LightPosition light_position_object_space 0
}
fragment_program_ref FragmentProgram/SimpleBump
{
}
texture_unit DiffuseMap
{
texture Armour.png
}
texture_unit NormalMap
{
texture ArmourBump.png
}
}
}
} |
Templates.material
Nazwaliśmy jednostki teksturujące, aby móc się do nich odnosić przy dziedziczeniu:
1
2
3
4
5
| material Man/TEXFACE/Armour.png : Template/SimpleBump
{
set_texture_alias DiffuseMap Armour.png
set_texture_alias NormalMap ArmourBump.png
} |
Man.material
Skróciliśmy tym samym plik materiału postaci ponad pięciokrotnie.
Wszystko to osiągnęliśmy bez dokonywania jakichkolwiek zmian w kodzie źródłowym gry. Oczywiście używane przez nas programy cieniujące są bardzo proste i nie uwzględniają wielu elementów oświetlenia. Takimi problemami jak światło odbite (
specular) - połysk i wygasanie wraz z odległością (
attenuation).
Oświetlenie Blinna-Phonga
Poprawki kamery
Zanim zaczniemy majstrować przy oświetleniu musimy dokonać usprawnień kamery. Do tej pory bardzo brzydko przenikała przez ściany planszy. Da się temu zaradzić - Newton ma możliwość sprawdzania kolizji promieni - wypuścimy więc promień z głowy postaci do pożądanej pozycji kamery i w przypadku kolizji wyciągniemy informację o miejscu jej wystąpienia.
Przygotujemy sobie klasę do rzucania promieni, która będzie pozwalała nam odfiltrować wybrane obiekty, np. wyzwalacze przestrzenne, które nie powinny blokować kamery:
1
2
3
4
5
6
7
8
| public class PredicateRaycast : Raycast
{
public class ContactInfo
{
public float Distance;
public Body Body;
public Vector3 Normal;
} |
Raycaster.cs
W obiektach podklasy
ConctactInfo
będziemy przchowywać informacje o pojedynczych kolizjach promienia.
1
2
3
4
5
6
7
8
9
10
11
12
13
| public Predicate<Body> Predicate;
public List<ContactInfo> Contacts;
public PredicateRaycast(Predicate<Body> pred)
{
Predicate = pred;
Contacts = new List<ContactInfo>();
}
public override bool UserPreFilterCallback(Body body)
{
return Predicate(body);
} |
Raycaster.cs
Za filtrowanie ciał będzie odpowiadać predykat ustalny przy konstruowaniu promienia. Metoda UsePreFilterCallback() jest wywoływana w momencie natrafienia przez promień prostopadłościanu otaczającego ciało.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public void SortContacts()
{
Contacts.Sort((a, b) => a.Distance.CompareTo(b.Distance));
}
public override bool UserCallback(Body body, float distance, Vector3 normal, int collisionID)
{
ContactInfo contact = new ContactInfo();
contact.Distance = distance;
contact.Body = body;
contact.Normal = normal;
Contacts.Add(contact);
return true;
}
} |
Raycaster.cs
Nie jest określone w jakiej kolejności Newton będzie sprawdzać ciała, więc musimy sami zadbać o sortowanie kontaktów w kolejności. Metoda UserCallback() jest wywoływana w momencie natrafienia przez promień ciała i uprzednim zaakceptowaniu przez UserPreFilterCallback().
Klasa kamery
W klasie kamery wprowadzane zmiany nie będą duże. Dopisujemy pole:
1
| public Vector3 InterPosition; |
GameCamera.cs
Będzie ono przechowywać porządaną pozycję kamery po interpolacji.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| public void Update()
{
Vector3 offset =
Character.Node.Orientation * (-Vector3.UNIT_Z +
(Vector3.UNIT_Y * (float)System.Math.Tan(Angle.ValueRadians))
).NormalisedCopy * Distance;
Vector3 head = Character.Node.Position + Character.Profile.HeadOffset;
Vector3 desiredPosition = head + offset;
InterPosition += (desiredPosition - InterPosition) * 0.1f;
PredicateRaycast raycast = new PredicateRaycast((b => !(b.UserData is TriggerVolume)));
raycast.Go(Engine.Singleton.NewtonWorld, head, InterPosition);
if (raycast.Contacts.Count != 0)
{
raycast.SortContacts();
Engine.Singleton.Camera.Position = head
+ (InterPosition - head) * raycast.Contacts[0].Distance
+ raycast.Contacts[0].Normal * 0.15f;
}
else
Engine.Singleton.Camera.Position = InterPosition;
Engine.Singleton.Camera.LookAt(head);
} |
GameCamera.cs
Najpierw obliczamy porządaną pozycję po interpolacji (wiersz 11), następnie tworzymy promień pomijający wyzwalacze (wiersz 13), wypuszczamy go z głowy do pożądanej pozycji (wiersz 14), w przypadku wystąpienia kolizji ustawiamy kamerę w miejscu wystąpienia kolizji przesuniętym o 15 cm wzdłuż wektora normalnego kolidującej płaszczyzny, ponieważ kamera posiada określony minimalną odległość widzenia (
near clip plane).
Programy cieniujące
Chcemy stworzyć użyteczny model oświetlenia, który będzie w stanie obsłużyć wiele świateł. Nie obejdzie się więc bez renderowania wielobrzebiegowego. Aby nie komplikować problemu, posłużymy się dodawaniem przebiegów. Ponieważ będziemy dodawać wartości kolorów do poprzednich przebiegów, musimy przygotować sobie punkt wyjściowy - wyrenderować geometrię modelu na czarno. W przeciwnym przypadku oświetlenie modelu dodałoby się do tła sceny. Można by to uznać za pożądane, gdy chcielibyśmy stworzyć postać ducha:
Potrzebujemy więc napisać proste programy cieniujące tworzące czarny podkład pod oświetlenie. Pozostawimy sobie pewną swobodę, kolor podkładu uzależnimy od koloru tła sceny (ambientu).
Ambient: Vertex-program
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| struct VertexIn
{
float4 Position : POSITION;
};
struct VertexOut
{
float4 Position : POSITION;
};
VertexOut vertex_func(VertexIn In,
uniform mat4x4 WorldViewProj
)
{
VertexOut Out;
Out.Position = mul(WorldViewProj, In.Position);
return Out;
} |
AmbientVP.cg
Najprostszy vertex-program.
Ambient: Fragment-program
1
2
3
4
5
6
7
8
9
10
11
| struct FragmentOut
{
float4 Color : COLOR;
};
FragmentOut fragment_func(uniform float4 Ambient)
{
FragmentOut Out;
Out.Color = Ambient;
return Out;
} |
AmbientVP.cg
Pobieramy zmienną Ambient jako ambient_light_colour z Ogre.
Te programy będą wykorzystane w pierwszym przebiegu. Teraz przejdziemy do sedna. Będziemy korzystać z modelu cieniowania zwanego modelem Blinna-Phonga. Z częścią tego modelu mieliśmy już do czynienia pisząc najprostszy shader oświetlenia. Teraz dodamy światło odbite -
specular - które bardzo ładnie podkreśli detale zawarte w mapach wypukłości. W modelu Blinna-Phonga kolor światła odbitego określony jest wzorem:
N jest wektorem normalnym powierzchni, a H wektorem połowy kąta patrzenia (wektor będący uśrednieniem wektora kierunku światła i wektora kierunku patrzenia). Potęga n reguluje "ostrość" odbicia. Pomimo, że dystrybucja światła tego modelu nie posiada odbicia w rzeczywistości, rezultaty są całkowicie akceptowalne.
Oświetlenie: Vertex-program
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| struct VertexIn
{
float4 Position : POSITION;
float2 TexCoord : TEXCOORD0;
float3 Normal : NORMAL;
float3 Tangent : TANGENT;
};
struct VertexOut
{
float4 Position : POSITION;
float2 TexCoord : TEXCOORD0;
float3 LightDir : TEXCOORD1;
float3 HalfAngle : TEXCOORD2;
};
VertexOut vertex_func(VertexIn In,
uniform mat4x4 WorldViewProj,
uniform float4 LightPosition,
uniform float3 EyePosition
)
{
VertexOut Out;
Out.Position = mul(WorldViewProj, In.Position);
Out.TexCoord = In.TexCoord;
float3 lightDir = LightPosition.xyz - (In.Position * LightPosition.w);
float3 binormal = cross(In.Normal, In.Tangent);
float3x3 tbnMatrix = float3x3(In.Tangent, binormal, In.Normal);
Out.LightDir = mul(tbnMatrix, lightDir);
float3 eyeDir = normalize(EyePosition - In.Position.xyz);
Out.HalfAngle = mul(tbnMatrix, normalize(eyeDir + lightDir));
return Out;
} |
BumpVP.cg
Kod wygląda podobnie do kodu programu SimpleBump. Dodatkowo liczymy wspomniany wektor
HalfAngle i przenosimy go do przestrzeni stycznej.
Oświetlenie: Fragment-program
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| struct FragmentIn
{
float2 TexCoord : TEXCOORD0;
float3 LightDir : TEXCOORD1;
float3 HalfAngle : TEXCOORD2;
};
struct FragmentOut
{
float4 Color : COLOR;
};
FragmentOut fragment_func(FragmentIn In,
uniform sampler2D DiffuseMap : TEXUNIT0,
uniform sampler2D NormalMap : TEXUNIT1,
uniform sampler2D SpecularMap : TEXUNIT2,
uniform float4 LightAttenuation,
uniform float4 LightColour
)
{
FragmentOut Out;
float3 normal = (tex2D(NormalMap, In.TexCoord) - 0.5) * 2.0;
float lightDist = length(In.LightDir);
float attenuation = 1.0 / (LightAttenuation.y
+ LightAttenuation.z * lightDist
+ LightAttenuation.w * lightDist * lightDist
);
Out.Color = tex2D(DiffuseMap, In.TexCoord)
* saturate(dot(normal, normalize(In.LightDir))) * attenuation;
Out.Color *= LightColour;
float specular = saturate(pow(dot(In.HalfAngle, normal), 16));
Out.Color += tex2D(SpecularMap, In.TexCoord)
* LightColour * specular * attenuation;
return Out;
} |
BumpFP.cg
Pierwszą znaczącym ulepszniem, które wprowadzamy jest wygasanie światła (
attenuation). Określone jest przez trzy parametry: constant, linear i quadratic. Mamy pełną dowolność w kalibracji tych współczynników. Najczęściej dobierane są tak, by wartość wygasania na granicy zasięgu światła była bliska zeru. Parametry te będzie przekazywać silnik, a ustalać je będziemy mogli za pomocą metody obiektu światła w kodzie gry.
Obliczamy współczynnik światła rozproszonego zgodnie z modelem Blinna-Phonga. Funkcja saturate() przycina wartość zmiennej do przedziału [0,1]. Tekstura SpecularMap pozwoli nam kontrolować odbijanie światła przez różne materiały.
Oświetlenie: Szablon materiału
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| material Templates/Bump
{
technique
{
pass
{
lighting off
vertex_program_ref VertexProgram/Ambient
{
param_named_auto WorldViewProj worldviewproj_matrix
}
fragment_program_ref FragmentProgram/Ambient
{
param_named_auto Ambient ambient_light_colour
}
}
pass
{
iteration once_per_light
scene_blend add
vertex_program_ref VertexProgram/Bump
{
param_named_auto WorldViewProj worldviewproj_matrix
param_named_auto LightPosition light_position_object_space 0
param_named_auto EyePosition camera_position_object_space
}
fragment_program_ref FragmentProgram/Bump
{
param_named_auto LightAttenuation light_attenuation 0
param_named_auto LightColour light_diffuse_colour 0
}
texture_unit DiffuseMap
{
texture empty
}
texture_unit NormalMap
{
texture empty
}
texture_unit SpecularMap
{
texture empty
}
}
}
} |
Templates.material
Pierwszy przebieg renderuje geometrię bez oświetlenia, kolejne mogą ją tylko rozjaśniać. Korzystając z tego szablonu musimy pamiętać o ustaleniu tekstury SpecularMap.
Testowy świat
Dla celów testowych zmieńmy świat. Potrzebujemy mapki z wygenerowanymi wektorami stycznymi i wszystkimi potrzebnymi teksturami.
Ponieważ nowych zasobów jest dużo, w paczce znajduje się cała aplikacja wraz ze wszystkimi zaktualizowanymi zasobami.
Całość aplikacji i zasobów
Aktualny kod źródłowy
Usunięte ze sceny zostały wazy i dodane zostały światła w miejscach świecących kryształów. Model cieniowania został zmieniony na cienie wolumetryczne sumujące się - Ogre po prostu zamaskuje zacienione obszary zaopobiegając wykonywaniu fragment-programu dla nich.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| static void Main(string[] args)
{
// ustalamy kolor geometrii tła (nieoświetlonej)
Engine.Singleton.SceneManager.AmbientLight = new ColourValue(0,0,0);
...
CreateLight(new ColourValue(0.2f, 0.8f, 1.0f), new Vector3(11.1f, 1.18f, -4.8f));
CreateLight(new ColourValue(0.2f, 0.8f, 1.0f), new Vector3(13.1f, -0.44f, -12.68f));
CreateLight(new ColourValue(0.2f, 0.8f, 1.0f), new Vector3(9.34f, 1.57f, -20.61f));
CreateLight(new ColourValue(0.2f, 0.8f, 1.0f), new Vector3(22.85f, -0.44f, -10.15f));
CreateLight(new ColourValue(1f, 0.8f, 0.55f), new Vector3(0, 0, 0));
Engine.Singleton.SceneManager.ShadowTechnique = ShadowTechnique.SHADOWTYPE_STENCIL_ADDITIVE;
...
}
static void CreateLight(ColourValue colour, Vector3 pos)
{
Light crystalLight = Engine.Singleton.SceneManager.CreateLight();
crystalLight.Type = Light.LightTypes.LT_POINT;
crystalLight.Position = pos;
crystalLight.DiffuseColour = colour;
crystalLight.SetAttenuation(32, 1.0f, 0.14f, 0.07f);
} |
Program.cs
Udało nam się uzyskać dużą poprawę w stosunku do poprzedniej szaty graficznej. Oczywiście wciąż wiele zostało do zrobienia, przykładowo miękkie cienie teksturowe, które wymagają szerszego omówienia.
Jednakże poprawa jakości grafiki ze strony programistycznej pociąga za sobą konieczność spędzenia dodatkowego czasu nad jej plastyczną stroną. Aby bowiem cieszyć się zaawansowanymi efektami musimy dostarczyć mapy wektorów normalnych i światła odbitego. Czy warto?