W tym artykule zajmiemy się kontynuacją projektu z pierwszego artykułu o renderingu wolumetrycznym [2], w którym utworzyliśmy mapę głębokości. Artykuł ten dostępny jest w ramce poniżej.
Dodatkowe efektyAnalizując głębokości kilku sąsiednich pikseli będziemy aproksymować normalną powierzchni w danym miejscu - otrzymujemy mapę normalnych. Posiadając informacje o normalnych, uda nam się dość prosto zasymulować oświetlenie kierunkowe. W zależności od kąta pomiędzy kierunkiem światła a normalną w danym punkcie, obliczymy natężenie światła rozproszonego (ang. diffuse) w tym miejscu (pikselu). Aby wygenerować cienie, będziemy dodatkowo śledzić promień od badanego punktu w stronę światła. Jeśli natrafimy po drodze na punkt materialny, nie oświetlamy sprawdzanego miejsca - światło nie dociera tutaj do obiektu.
Dodatkowo, obliczymy światło odbite (ang. specular) - w tym celu zbudujemy promień odbity, korzystając z normalnej powierzchni w danym punkcie oraz promienia od kamery do tego punktu. Otrzymany wektor powie nam, skąd powinno padać światło, aby po odbiciu od powierzchni raziło nas w kamerę. Porównując ten wektor z faktycznym kierunkiem światła, obliczymy natężenie światła odbitego. Analogicznie do światła rozproszonego, obliczając światło odbite będziemy śledzić promień odbity w poszukiwaniu ewentualnej przeszkody, aby wygenerować cienie.
Zwykle, gdy mówi się o świetle rozproszonym i odbitym w grafice komputerowej, wspomina się też światło otaczające (ang. ambient). Tutaj sprawa jest prosta - po prostu dodajemy do każdego piksela kolor, bez żadnego cieniowania. W programie przykładowym światło otaczające zostało pominięte.
Następnie wygenerujemy prostą mgłę, zabarwiając piksele na dany kolor, tym mocniej, im większa jest ich głębokość.
Na końcu dołożymy do tego wszystkiego post-processing w postaci lekkiego rozmycia i korekcji kontrastu.
Krok 5 - Budowanie mapy normalnych
W tym kroku zbudujemy mapę normalnych, czyli obliczymy normalne powierzchni odkrytych w punktach widocznych w poszczególnych pikselach. Oczywiście, ze względu na naturę naszego świata opisanego funkcją zwracającą wartość boolowską dla podanego punktu w przestrzeni, możemy jedynie przewidywać, że powierzchnie takie naprawdę istnieją i w pewnym stopniu je aproksymować. Nie wiemy nawet, czy materia w naszym świecie jest w każdym miejscu ciągła (fakt, w prawdziwym świecie też nie jest, ale to całkiem inna bajka). Mając mapę normalnych, będziemy mogli prosto wykonać różnego rodzaju obliczenia oświetlenia. |
Prostym sposobem aproksymowania płaszczyzny stycznej do powierzchni naszego obiektu w punkcie widocznym w danym pikselu jest prześledzenie dwóch promieni wystrzelonych z kamery, w kierunku prawie identycznym, co promień główny wystrzelony w stronę badanego piksela. Naturalnym i dającym dobry efekt sposobem jest użycie w tym celu promieni głównych sąsiednich pikseli i tak też postępujemy w przedstawionym programie. Jeśli wzięlibyśmy promienie wystrzelone gęściej (w sensie kąta), obraz sprawiałby wrażenie zbyt ostrego, ponieważ wyznaczona płaszczyzna byłaby zbyt dokładna - uwzględniałaby mikronierówności naszego obiektu, których nie widać na pojedynczym pikselu. Jeśli natomiast weźmiemy zbyt odległe (znowu w sensie kąta) promienie, kształty wyznaczone przez normalne będą rozmyte. Gdy znamy już 2 bliskie punkty na powierzchni renderowanego obiektu, budujemy wektory left i up, które definiują płaszczyznę. Na końcu pozostaje już tylko obliczenie iloczynu wektorowego (ang. cross product) wektorów left i up - wynikiem jest normalna powierzchni w rozpatrywanym punkcie.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | for (int y = 1; y < sizeY + 1; ++y) { for (int x = 1; x < sizeX + 1; ++x) { // Nie rozważamy pikseli, na których nie widać żadnego punktu świata if (pixels[x, y].depth > farClipping) continue; // Obliczanie normalnej Vector left = pixels[x - 1, y].worldPoint - pixels[x, y].worldPoint; Vector up = pixels[x, y - 1].worldPoint - pixels[x, y].worldPoint; pixels[x, y].normal = Vector.Cross(left, up); pixels[x, y].normal.Normalize(); // (...) } // (...) } |
Na potrzeby tego artykułu, będziemy korzystać jedynie ze światła kierunkowego, tj. światła, które w każdym miejscu pada z tego samego kierunku. Możemy popatrzeć na to tak, że źródłem światła jest nieskończenie odległy punkt. W świecie rzeczywistym takie światło przypomina słońce oświetlające pewne niewielkie obiekty na powierzchni ziemi. Będziemy symulować rozproszenie i odbicie takiego światła.
Światło rozproszone jest podstawowym i najważniejszym rodzajem światła, jakie będziemy symulować. Właściwie, wystarczyłoby ono, aby wyrenderowany obraz wyglądał już dość dobrze. Zakładamy, że promień takiego światła po zetknięciu z powierzchnią odbija się w każdym możliwym kierunku z jednakową intensywnością - w tym w stronę kamery. Jednak intensywność ta zależy od kąta pomiędzy kierunkiem światła a normalną płaszczyzny stycznej do powierzchni w rozpatrywanym punkcie: najmocniej oświetlone będą powierzchnie, na które światło pada prostopadle. Symulację należy przeprowadzić dla wszystkich pikseli, badając intensywność światła odbitego od punktu widocznego w danym pikselu.
Zauważmy, że na rysunku nie ma zaznaczonej kamery - pozycja obserwatora nie ma znaczenia, ponieważ założyliśmy, że światło jest odbite z jednakową intensywnością w każdym kierunku.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | foreach (Light l in lights) { for (int y = 1; y < sizeY+1; ++y) { for (int x = 1; x < sizeX+1; ++x) { if (pixels[x, y].depth > farClipping) continue; // Sprawdzamy, czy punkt nie jest w cieniu if (Collision(pixels[x, y].worldPoint, -l.direction, false)) continue; double intensity = pixels[x, y].normal * -l.direction; if (intensity < 0) intensity = 0; pixels[x, y].color += intensity * l.color; } // (...) } } |
W powyższym fragmencie programu najpierw sprawdzamy, czy przetwarzany punkt jest w cieniu, śledząc promień od tego punktu w stronę światła. Jeśli napotkamy przeszkodę, punkt jest w cieniu - nie dodajemy żadnego światła do piksela. W przeciwnym wypadku, obliczamy intensywność światła za pomocą iloczynu skalarnego normalnej i odwróconego wektora światła (wektory te zaznaczone są na rysunku). Oba te wektory są znormalizowane, więc w efekcie otrzymujemy cosinus kąta pomiędzy nimi (kąt także został zaznaczony na rysunku). To, dlaczego używamy akurat funkcji cosinus, można łatwo sobie wyobrazić - możemy rozważyć (w dwóch wymiarach) strumień światła pewnej niewielkiej szerokości i zaobserwować, jak zmienia się długość oświetlonego odcinka prostej, w zależności od kąta, pod jakim światło pada na prostą. Jeśli odcinek jest dłuższy, to światło traci intensywność, ponieważ oświetla większy obszar.
Krok 7 - Światło odbiteŚwiatło odbite to światło, którego kąt odbicia jest bliski kątowi padania (w przeciwieństwie do światła rozproszonego, które odbija się w każdym kierunku równomiernie). Im kąt odbicia jest bliższy kątowi padania, tym intensywniej światło się odbija. W prawdziwym świecie możemy takie światło porównać do np. nieba odbijającego się w kałuży. W przedstawionym programie będziemy śledzić światło „od końca”; tj. śledząc promień od kamery w stronę renderowanego piksela sprawdzimy, czy światło miało szansę odbić się od powierzchni obiektu tak, aby „oślepić” kamerę.
Aby otrzymać odbity promień od kamery do piksela (reflected), możemy np. rzutować -pixel.ray na pixel.normal. Rzutowanie wektora na inny wektor polega na przemnożeniu wektora, na który rzutujemy przez iloczyn skalarny obu wektorów. Otrzymany wektor dwukrotnie dodajemy do pixel.ray.
W prosty sposób intensywność światła odbitego możemy obliczyć jako cosinus kąta pomiędzy reflected i -l.direction (kąt ten jest wyróżniony na rysunku na niebiesko). Aby jednak refleksy świetlne były mniejsze, co daje znacznie lepszy efekt wizualny, przemnażamy kąt pomiędzy wektorami o pewien współczynnik typowy dla danego światła (l.specularSize):
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 | foreach (Light l in lights) { for (int y = 1; y < sizeY + 1; ++y) { for (int x = 1; x < sizeX + 1; ++x) { Pixel pixel = pixels[x, y]; if (pixel.depth > farClipping) continue; // Zauważmy, że: // pixel.normal * (-pixel.ray) == -(pixel.normal * pixel.ray) double dot = -(pixel.normal * pixel.ray); Vector reflected = 2 * dot * pixel.normal + pixel.ray; // Zauważmy, że: // reflected * (-l.direction) == -(reflected * l.direction) double intensity = -(reflected * l.direction); double angle = Math.Acos(intensity) * l.specularSize; if (angle >= Math.PI*0.5) continue; // Sprawdzamy, czy punkt nie jest w cieniu if (Collision(pixels[x, y].worldPoint, reflected, false)) continue; intensity = Math.Cos(angle); intensity *= intensity; pixels[x, y].color += intensity * l.color; } // (...) } } |
Wykorzystując wcześniej zapisaną mapę głębokości, możemy prosto utworzyć czarną mgłę. Przyciemniamy punkty w zależności od ich odległości od kamery. Dodatkowo używamy tutaj wcześniej obliczonych przy okazji wartości: minimalnej i maksymalnej głębokości piksela (maksymalnej spośród skończonych głębokości). Punkty leżące w minimalnej głębokości nie będą w ogóle zamglone, natomiast punkty leżące w maksymalnej głębokości będą zamglone ze współczynnikiem 0.8.
1 2 3 4 5 6 7 8 9 10 11 | double depthAmplitudeInv = 0.8 / (maxDepth - minDepth); for (int y = 1; y < sizeY + 1; ++y) { for (int x = 1; x < sizeX + 1; ++x) { double depth = (pixels[x, y].depth - minDepth) * depthAmplitudeInv; pixels[x, y].color *= 1 - depth; } // (...) } |
Krok 9 - Proste rozmycie
Aby pozbyć się ostrych krawędzi, refleksów itp., możemy nałożyć na obraz lekkie rozmycie. W naszym programie robimy to, zlewając piksel z dwoma sąsiednimi pikselami. Dodatkowo, wynik zapisujemy natychmiast do danego piksela i używamy nowego koloru piksela przy przetwarzaniu kolejnych pikseli. Jest to bardzo proste rozwiązanie, bo nie potrzebuje żadnej dodatkowej pamięci. Dodatkowo, rozmycie jest propagowane dużo dalej niż tylko do jednego sąsiedniego piksela, uzyskujemy więc rozmycie o większym „promieniu” za darmo - algorytm rozmywania wciąż ma kwadratową złożoność czasową. Wadą tego rozwiązania jest to, że rozmywamy tylko w prawo i w dół. Można temu zapobiec, powtarzając algorytm w odwrotnym kierunku, ale nie robimy tego w naszym programie.
1 2 3 4 5 6 7 8 9 10 11 12 | for (int y = 2; y < sizeY + 1; ++y) { for (int x = 2; x < sizeX + 1; ++x) { pixels[x, y].color = 0.5 * pixels[x, y].color + 0.25 * pixels[x - 1, y].color + 0.25 * pixels[x, y - 1].color; } // (...) } |
Krok 10 - Korekcja kontrastu
Aby w pełni wykorzystać dostępny zakres kolorów, skalujemy każdą składową każdego koloru tak, aby najciemniejsza składowa spośród składowych wszystkich pikseli (z wyłączeniem tła, które jest całkowicie czarne) była ustawiona na 0, natomiast najjaśniejsza na 1. W tym celu najpierw szukamy najjaśniejszej i najciemniejszej składowej, a następnie, w drugim przejściu przez wszystkie piksele, skalujemy odpowiednio kolory wszystkich pikseli.
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 | // Szukamy najjaśniejszej i najciemniejszej składowej double min = double.MaxValue; double max = double.MinValue; for (int y = 1; y < sizeY + 1; ++y) { for (int x = 1; x < sizeX + 1; ++x) { Vector color = pixels[x, y].color; double currentMin = color.Min; double currentMax = color.Max; if (currentMax == 0) continue; min = Math.Min(min, currentMin); max = Math.Max(max, currentMax); } } // Wykonujemy skalowanie double amplitude = max - min; max -= whiteCut * amplitude; min += blackCut * amplitude; amplitude = max - min; double amplitudeInv = 1 / amplitude; for (int y = 1; y < sizeY + 1; ++y) { for (int x = 1; x < sizeX + 1; ++x) { Vector color = pixels[x, y].color; color.x = (color.x - min) * amplitudeInv; color.y = (color.y - min) * amplitudeInv; color.z = (color.z - min) * amplitudeInv; } // (...) } |
Zakończenie
Gorąco zachęcam do eksperymentów nad renderingiem wolumetrycznym. Szczególnie interesujące są różne fraktale i wykresy dwuargumentowych funkcji matematycznych działających na liczbach rzeczywistych. W przykładowym programie (do ściągnięcia po prawej), w pliku MainForm.cs, zostało zakomentowanych parę scenek. Ze względu na możliwość równoległego obliczania poszczególnych pikseli, dobrym pomysłem byłoby renderowanie za pomocą GPU, np. przy użyciu nVidia CUDA, OpenAL albo DirectCompute (przy odpowiednim zrównolegleniu algorytmu, GPU jest szybsze od CPU kilkaset razy). Być może wtedy udałby się rendering w czasie rzeczywistym!
Odnośniki:
[1] http://atablash.pl
[2] http://informatyka.wroc.pl/795
[3] http://informatyka.wroc.pl/../upload/atablash/raycast/VolumeRayCaster_DepthMap.zip
[4] http://informatyka.wroc.pl/../upload/atablash/raycast/VolumeRayCaster_Final.zip
[5] http://informatyka.wroc.pl/989?page=0,1
[6] http://informatyka.wroc.pl/989?page=0,2
[7] http://informatyka.wroc.pl/989?page=0,3
[8] http://informatyka.wroc.pl/989?page=0,4
[9] http://informatyka.wroc.pl/989?page=0,5
[10] http://informatyka.wroc.pl/989?page=0,6
[11] http://informatyka.wroc.pl/989?page=0,7