Budujemy silnik: organizacja shaderów
18.12.2008 20:45 in grafika 3D, cg, OpenGL, programowanie, silnik
Jak możemy zarządzać kodem shaderów w silniku? Podstawowe podejścia są trzy:
- przygotować stały zestaw shaderów i dać użytkownikowi jedynie możliwość wyboru spośród nich. Podejście to sprawdzi się, niestety, jedynie w bardzo prostych scenach.
- metoda addytywna - przez niektórych uznawana za Święty Graal. Polega na ,,rysowaniu'' (a przynajmniej układaniu) shadera w sposób graficzny (przykład modelu addytywnego). Szczerze powiedziawszy do mnie zupełnie ten sposób nie trafia. Bo kto niby miałby te shadery układać? Grafik? Bez sensu, nawet jeżeli radziłby sobie z ich edycją (rzecz nietrywialna), to jak miałby uwzględniać różne parametry sceny? Map designer? Cieplej, ale czy map designer musi być inżynierem ds. shaderów? Ostatecznie może to robić programista -- tylko po co, skoro w żaden sposób nie ułatwia mu to zadania?
- wreszcie mamy metodę substraktywną - polegającą na napisaniu (w uproszczeniu) jednego wielkiego shadera, z którego będziemy wybierali potrzebne części. Skupię się właśnie na niej.
Z tą metodą spotkałem się po raz pierwszy w silniku The Final Quest Adama Sawickiego. Ideę rozwinąłem do następującej postaci:
- uwaga formalna: korzystam z Cg
- wszystkie parametry są deklarowane na początku pliku shadera
- kluczowy rodzaj materiału jest zdefiniowany jako osobna procedura (entry point). Przykładowo, w moim silniku osobnymi procedurami są shadery simple (diffuse+specular), metallic (environmental mapping), bump (normal/parallax mapping), water
- wszystkie inne parametry zdefiniowane są jako kombinacje makr
- możliwie spójne bloki funkcjonalne są definiowane jako funkcje wykorzystujące zmienne ,,globalne'' - przykład: liczenie zaniku światła:
// paramtery materiału, światła, etc.
float light_strength
float3 light_direction;
...
// zmienne globalne
float Attenuation;
float3 LightVector;
float3 LightDirection;
...
// procedury
void CalculateAttenuation()
{
float light_strength = light_strength.x;
if (light_strength < 0)
{
Attenuation = 1;
}
else
{
float len = dot(LightVector, LightVector);
Attenuation = 1 - len / light_strength;
}
#ifdef LIGHT_SPOT
if (Attenuation > 0)
{
float spotEffect = dot(normalize(light_direction.xyz),
-normalize(LightDirection));
float g_spot_theta = radians(light_cutoff.x);
float g_spot_phi = radians(light_cutoff.y);
float inner_alpha = cos(g_spot_theta / 2.0f);
float outer_alpha = cos(g_spot_phi / 2.0f);
Attenuation *= saturate((spotEffect - outer_alpha) /
(inner_alpha - outer_alpha));
}
#endif
}
- przykładowa procedura wejścia wygląda następująco:
float4 pixel_simple() : COLOR
{
float4 o_color;
TEST_SHADOW; // makro, które przy świetle rzucającym cień może
// odrzucić (discard) fragment
CalculateTheDot(); // CTD() wywołuje CalculateAttenuation,
// CalculateLightVector i inne
o_color.rgb = TheDot * Attenuation;
o_color.a = material_color.w; // przezroczystość materiału
// przy alpha blendingu
return o_color;
}
- póki co, CTD() jest u mnie dosyć ,,podstawową'' operacją, z której korzystają materiały zwykłe i metaliczne. TEST_SHADOW uruchamia funkcję TestShadow(), jeżeli światło rzuca cień
- utrzymuję brak zagnieżdzeń instrukcji preprocesora, tworząc w razie potrzeby osobne bloki
Zaletą przedstawionego rozwiązania jest możliwość ciągłego rozwijania kodu shadera bez potrzeby gwałtownej refaktoryzacji. ;) Wadą jest ilość możliwych kombinacji - u mnie jest to póki co 5 * 25 = 160 różnych shaderów. Każdy z nich ma 13 parametrów (2080 CGparameter łącznie). Można to oczywiście rozwiązać przez kompilację na żądanie, bądź po prostu przy gotowej finalnej wersji danej sceny/mapy wygenerować statyczną listę użytych shaderów. Podziękowania należą się nVidii za stworzenie rewelacyjnego kompilatora cgc, który dokonuje zaawansowanych optymalizacji kodu pozwalających na pisania w przejrzysty, strukturalny sposób.
PS. Jeżeli znacie lepsze metody na zorganizowanie shaderów, pochwalcie się nimi. :)
Comments:
-
Reg:
Metody addytywnej wcale nie trzeba realizować przez składanie bloczków w spsób graficzny w specjalnym edytorze (ja też tego nie popieram). Można utworzyć własny język do definiowania bloków shadera i łączenia ich wyjść z wejściami, tak jak w tym artykule: http://www.talula.demon.co.uk/hlsl_fragments/hlsl_fragments.html.
21.12.2008 13:07:14
-
Poszczególne shadery wycinasz #ifdefami czy znalazłeś coś lepszego? Sam tej metody nie stosowałem, ale u Rega wyszedł z tego afaik 1000-linijkowy shader, raczej spaghetti.
24.12.2008 23:26:46
-
Stosuję #ifdefy, bo co mi zostaje? Z tym że u Rega był to jeden bardzo długi shader, a u mnie wygląda to tak:
float4 pixel_environment( ) : COLOR
{
TEST_CLIP;
TEST_SHADOW;
float4 o_color;
float3 color_dot = classic_dot();
float3 color_env = texCUBE(tex_cube, i_R).rgb * light_color.rgb;
o_color.rgb = lerp(color_env, color_dot, texture_params.b);
o_color.rgb *= Attenuation;
o_color.a = material_color.w;
return o_color;
}
#ifdefy są głównie na poziomie poszczególnych funkcji (jak w przykładzie z attenuation) i trochę łatwiej jest zrozumieć jak shader działa dla konkretnych parametrów.
Ale nie jest to jeszcze szczyt marzeń. Problemem jest zwłaszcza rosnąca ciągle liczba parametrów. A kiedy nie chcąc dodawać kolejnych, wykorzystuję stare, to muszę pamiętać, że material_color.w to alpha, a eye_pos.a to czas od początku gry (bo tam akurat było wolne miejsce). Tak, to ssie. Jak stwierdzę, że shadery mają największy WTF/LC w moim silniku to pewnie zaimplementuję coś lepszego.25.12.2008 00:37:26