This is the Polish translation of Advanced-OpenGL/Cubemaps article of learnopengl.com tutorial series.

Od jakiegoś czasu używamy tekstur 2D, ale jest jeszcze więcej typów tekstur, których jeszcze nie odkryliśmy. W tym samouczku omówimy typ tekstury, który jest w rzeczywistości kombinacją wielu tekstur zmapowanych w jedną strukturę: cube map.

Cube mapa to zasadniczo tekstura zawierająca 6 pojedynczych tekstur 2D, z których każda tworzy jedną ściankę sześcianu: oteksturowaną kostkę od wewnątrz. Możesz się zastanawiać, jaki jest sens takiego sześcianu? Po co zawracać sobie głowę łączeniem 6 pojedynczych tekstur w jeden obiekt, zamiast używania 6 pojedynczych tekstur? Cube mapy mają przydatną właściwość, że mogą być one indeksowane/próbkowane za pomocą wektora kierunkowego. Wyobraźmy sobie, że mamy sześcian jednostkowy 1x1x1, gdzie początek wektora kierunkowego znajduje się w jego środku. Próbkowanie wartości tekstury z cube mapy za pomocą pomarańczowego wektora kierunku wygląda mniej więcej tak:

Indeksowanie/Próbkowanie z cube mapy w OpenGL

Wielkość wektora kierunku nie ma znaczenia. Dopóki dostarczany jest kierunek, OpenGL wyszukuje odpowiadające teksele, w które trafiamy na bazie dostarczonego kierunku i zwraca poprawnie spróbkowaną wartość tekstury.

Jeśli wyobrazimy sobie, że mamy kształt sześcianu, do którego przypinamy taką mapę, to wektor kierunkowy do próbkowania cube mapy byłby podobny do (interpolowanej) pozycji wierzchołka sześcianu. W ten sposób możemy próbkować cube mapę za pomocą rzeczywistych wektorów położenia kostki, o ile sześcian jest wyśrodkowany względem swojego punktu początkowego. Możemy wtedy pobrać współrzędne tekstury wszystkich wierzchołków jako pozycje wierzchołków sześcianu. Wynikiem jest współrzędna tekstury, która uzyskuje dostęp do odpowiedniej tekstury ścianki cube mapy.

Tworzenie cube mapy

Cube mapa jest teksturą podobną do każdej innej tekstury, więc aby ją utworzyć, generujemy teksturę i wiążemy ją z właściwym typem docelowym tekstury, zanim wykonamy dalsze operacje na teksturze. Tym razem powiążemy z typem GL_TEXTURE_CUBE_MAP:

    unsigned int textureID;
    glGenTextures(1, &textureID);
    glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

Ponieważ cube mapa składa się z 6 tekstur, po jednej dla każdej ścianki, musimy wywołać sześć razy glTexImage2D z parametrami ustawionymi na wartości podobne z poprzednich samouczków. Tym razem jednak musimy ustawić parametr tekstury target na konkretną ścianę cube mapy, mówiąc w zasadzie OpenGL, po której stronie cube mapy tworzymy teksturę. Oznacza to, że musimy wywołać glTexImage2D raz dla każdej ścianki cube mapy.

Ponieważ mamy 6 ścianek, OpenGL zapewnia nam 6 specjalnych typów docelowych tekstur specjalnie do powiązania danej tekstury z odpowiednią ścianką cube mapy:

Typ docelowy tekstury Orientacja
GL_TEXTURE_CUBE_MAP_POSITIVE_X Prawo
GL_TEXTURE_CUBE_MAP_NEGATIVE_X Lewo
GL_TEXTURE_CUBE_MAP_POSITIVE_Y Góra
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y Dół
GL_TEXTURE_CUBE_MAP_POSITIVE_Z Tył
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z Przód

Podobnie jak wiele z typów wyliczeniowych OpenGL, ich wartości int są zwiększane o jeden, więc gdybyśmy mieli tablicę lub wektor tekstur, moglibyśmy iterować po nich, zaczynając od GL_TEXTURE_CUBE_MAP_POSITIVE_X i zwiększać licznik pętli o 1 w każdej iteracji, skutecznie iterując po wszystkich typach docelowych tekstury:

    int width, height, nrChannels;
    unsigned char *data;  
    for(GLuint i = 0; i < textures_faces.size(); i++)
    {
        data = stbi_load(textures_faces[i].c_str(), &width, &height, &nrChannels, 0);
        glTexImage2D(
            GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 
            0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data
        );
    }

Tutaj mamy vector o nazwie textures_faces, który zawiera lokalizacje wszystkich tekstur wymaganych dla cube mapy w kolejności podanej w tabeli. Generuje to teksturę dla każdej ścianki aktualnie powiązanej cube mapy.

Ponieważ cube mapa jest teksturą jak każdą inna tekstura, określimy również jej metody zawijania i filtrowania:

    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);  

Nie bój się wartości GL_TEXTURE_WRAP_R, to po prostu ustawia metodę zawijania dla współrzędnej R tekstury, która odpowiada trzeciemu wymiarowi tekstury (jak z dla pozycji). Ustawiamy metodę zawijania na GL_CLAMP_TO_EDGE, ponieważ współrzędne tekstur, które znajdą się pomiędzy dwiema ściankami mogą nie dotyczyć dobrej ścianki (ze względu na pewne ograniczenia sprzętowe), więc używając GL_CLAMP_TO_EDGE OpenGL zawsze zwraca ich wartości znajdujące się na krawędzi, gdy próbkujemy pomiędzy ściankami.

Następnie przed rysowaniem obiektów, które będą korzystać z cube mapy, aktywujemy odpowiednią jednostkę tekstur i wiążemy ją przed renderowaniem. Niewiele jest różnic w porównaniu do normalnych tekstur 2D.

W Fragment Shader musimy również użyć samplera typu samplerCube, który próbujemy z wykorzystaniem funkcji texture, ale tym razem używając wektora kierunku vec3 zamiast vec2. Przykład Fragment Shadera przy użyciu cube mapy wygląda następująco:

    in vec3 textureDir; // wektor kierunkowy reprezentujący współrzędną tekstury 3D
    uniform samplerCube cubemap; // sampler tekstury cube map

    void main()
    {             
        FragColor = texture(cubemap, textureDir);
    }  

To jest świetne, ale po co zawracać sobie tym głowę? Cóż, tak się składa, że ​​istnieje sporo interesujących technik, które są o wiele łatwiejsze do wdrożenia z cube mapą. Jedną z tych technik jest tworzenie skybox'ów.

Skybox

Skybox to (duży) sześcian, który obejmuje całą scenę i zawiera 6 tekstur otaczającego środowiska, dając graczowi złudzenie, że środowisko, w którym się znajduje jest w rzeczywistości o wiele większe, niż jest w rzeczywistości. Niektóre przykłady skyboxów używanych w grach wideo to obrazy gór, chmur lub gwiaździstego nocnego nieba. Przykład skyboxu, wykorzystującego obrazy rozgwieżdżonego nieba, można zobaczyć na poniższym zrzucie ekranu z trzeciej gry z serii The Elder Scrolls:

Obraz morrowinda ze skyboxem

Najprawdopodobniej odgadłeś, że skyboxy takie jak ten pasują cube map idealnie: mamy sześcian, który ma 6 ścianek i musi mieć teksturę na każdej z nich. Na poprzednim obrazie użyli kilku zdjęć nocnego nieba, aby dać iluzję graczowi, że jest w jakimś dużym wszechświecie, podczas gdy on rzeczywiście znajduje się w maleńkim pudełeczku.

Zwykle jest wystarczająco dużo zasobów online, w których można znaleźć takie skyboxy. Ta strona internetowa ma na przykład wiele skyboxów. Te obrazy zazwyczaj mają następujący wzór:

Obraz skyboxa dla cube mapy w OpenGL

Jeśli złożysz te 6 ścianek w kostkę, otrzymasz całkowicie oteksturowany sześcian symulujący duży krajobraz. Niektóre zasoby udostępniają skyboxy w takim formacie, w którym to przypadku trzeba ręcznie wyodrębnić 6 obrazów, ale w większości przypadków są one dostarczane jako 6 pojedynczych obrazów.

Ten szczególnie wysokiej jakości skybox jest tym, czego użyjemy dla naszej sceny i możemy go pobrać tutaj.

Ładowanie skybox’a

Ponieważ skybox jest tylko cube mapą, ładowanie skyboxa nie różni się zbytnio od tego, co widzieliśmy już wcześniej. Aby załadować skybox, użyjemy następującej funkcji, która akceptuje vector z 6 ścieżkami do tekstur:

    unsigned int loadCubemap(vector<std::string> faces)
    {
        unsigned int textureID;
        glGenTextures(1, &textureID);
        glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);

        int width, height, nrChannels;
        for (unsigned int i = 0; i < faces.size(); i++)
        {
            unsigned char *data = stbi_load(faces[i].c_str(), &width, &height, &nrChannels, 0);
            if (data)
            {
                glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 
                             0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, data
                );
                stbi_image_free(data);
            }
            else
            {
                std::cout << "Cubemap texture failed to load at path: " << faces[i] << std::endl;
                stbi_image_free(data);
            }
        }
        glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
        glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
        glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);

        return textureID;
    }  

Funkcja sama w sobie nie powinna być zbyt zaskakująca. Jest to w zasadzie cały kod ładowania cube mapy, który widzieliśmy w poprzedniej sekcji, ale połączony w jedną, pomocniczą funkcję.

Następnie, zanim wywołasz tę funkcję, zdefiniujemy odpowiednie ścieżki tekstur w wektorze w kolejności określonej przez typ wyliczeniowy cube mapy:

    vector<std::string> faces;
    {
        "right.jpg",
        "left.jpg",
        "top.jpg",
        "bottom.jpg",
        "front.jpg",
        "back.jpg"
    };
    unsigned int cubemapTexture = loadCubemap(faces);  

Teraz, gdy już załadowaliśmy skybox jako cube mapę z cubemapTexture jako jego id. Możemy teraz powiązać go z kostką, aby w końcu zastąpić brzydki, jasny kolor, którego używamy jako tło.

Wyświetlanie skybox’a

Ponieważ skybox jest rysowany na sześcianie, potrzebujemy kolejnego VAO, VBO i nowego zestawu wierzchołków, jak dla każdego innego obiektu. Możesz uzyskać jego dane w wierzchołków tutaj.

Cube mapę, która jest używana do oteksturowania kostki można spróbkować, używając pozycji kostki jako współrzędnych tekstury. Gdy sześcian jest umieszczony w punkcie (0,0,0), każdy z jego wektorów pozycji jest również wektorem kierunkowym zaczepionym w tym punkcie. Ten wektor kierunkowy jest dokładnie tym, czego potrzebujemy, aby uzyskać odpowiednią wartość tekstury w tej konkretnej pozycji sześcianu. Z tego powodu musimy dostarczać jedynie wektory położenia i nie potrzebujemy współrzędnych tekstury.

Do narysowania skyboxa potrzebujemy nowego zestawu shaderów, które nie są zbyt skomplikowane. Ponieważ mamy tylko jeden atrybut wierzchołka. Vertex Shader jest dość prosty:

    #version 330 core
    layout (location = 0) in vec3 aPos;

    out vec3 TexCoords;

    uniform mat4 projection;
    uniform mat4 view;

    void main()
    {
        TexCoords = aPos;
        gl_Position = projection * view * vec4(aPos, 1.0);
    }  

Zauważ, że interesującą częścią Vertex Shadera jest ustawienie wektorów pozycji wejściowych jako wyjściowych współrzędnych tekstury dla Fragment Shadera. Fragment Shader pobiera je jako dane wejściowe, aby spróbkować samplerCube:

    #version 330 core
    out vec4 FragColor;

    in vec3 TexCoords;

    uniform samplerCube skybox;

    void main()
    {    
        FragColor = texture(skybox, TexCoords);
    }

Fragment Shader jest względnie prosty. Pobieramy wektory pozycji wierzchołka jako wektor kierunku i wykorzystujemy go do próbkowania wartości tekstury z cube mapy.

Renderowanie skyboxa jest teraz łatwe, gdy mamy już teksturę cube mapy, po prostu wiążemy teksturę cube mapy z kontekstem, a sampler skybox jest automatycznie wypełniany przez cube mapę skyboxa. Aby narysować skybox, narysujemy go jako pierwszy obiekt na scenie i wyłączymy pisanie do bufora głębokości. W ten sposób skybox będzie zawsze rysowany na tle wszystkich innych obiektów.

    glDepthMask(GL_FALSE);
    skyboxShader.use();
    // ... ustawić macierze widoku i projekcji
    glBindVertexArray(skyboxVAO);
    glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
    glDrawArrays(GL_TRIANGLES, 0, 36);
    glDepthMask(GL_TRUE);
    // ... narysuj resztę sceny

Jeśli uruchomisz teraz aplikację, zobaczysz, że coś jest nie tak. Chcemy, aby skybox był wycentrowany wokół gracza, aby niezależnie od tego, jak daleko się poruszy, to gracz nigdy nie zbliży się do skyboxa, dając wrażenie, że otoczenie jest bardzo duże. Obecna macierz widoku przekształca wszystkie pozycje w skyboxie, obracając je, skalując i przesuwając, więc jeśli gracz się ruszy, cube mapa również się poruszy! Chcemy usunąć część translacyjną macierzy widoku, aby ruch nie wpłynął na wektory pozycji skyboxa.

Być może pamiętasz z tutoriala podstawowy oświetlenia, że możemy usunąć część translacyjną macierzy transformacji, biorąc lewą górną macierz 3x3 macierzy 4x4, co skutecznie usuwa komponent translacji. Możemy to osiągnąć, po prostu przekształcając macierz widoku na macierz 3x3 (usuwając translację) i przekształcając ją z powrotem w macierz 4x4:

    glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));  

To usuwa całą translację, ale zachowuje wszystkie transformacje rotacji, aby użytkownik mógł nadal rozglądać się po scenie.

Rezultatem jest scena, która natychmiast wygląda na olbrzymią dzięki naszemu skyboxowi. Jeśli będziesz latać wokół podstawowego pojemnika, natychmiast uzyskasz poczucie skali, które dramatycznie poprawia realizm sceny. Wynik wygląda mniej więcej tak:

Skybox w scenie OpenGL

Spróbuj poeksperymentować z różnymi skyboxami i zobacz, jaki mogą one mieć ogromny wpływ na wygląd i styl Twojej sceny.

Optymalizacja

Teraz renderowaliśmy skybox zanim wyrenderowaliśmy wszystkie inne obiekty na scenie. Działa to świetnie, ale nie jest zbyt wydajne. Jeśli najpierw wyrenderujemy skybox, uruchomimy Fragment Shader dla każdego piksela na ekranie, nawet jeśli tylko niewielka część nieba stanie się widoczna; fragmenty, które można było łatwo odrzucić za pomocą wczesnego testowania głębokości, oszczędzając nam cenną moc obliczeniową.

Aby dać nam niewielki wzrost wydajności, sprawimy, że skybox będzie rysował się ostatni. W ten sposób bufor głębi jest całkowicie wypełniony wszystkimi wartościami głębokości obiektów, więc musimy renderować fragmenty skyboxa wszędzie tam, gdzie przechodzi wczesny test głębokości, znacznie redukując wywołania Fragment Shader’a. Problem polega na tym, że skybox najprawdopodobniej przestanie się renderować, ponieważ jest to kostką 1x1x1, która nie przechodzi większości testów głębi. Po prostu renderowania jej bez testowania głębi nie jest rozwiązaniem, ponieważ skybox nadpisze wtedy wszystkie inne obiekty na scenie. Musimy oszukać bufor głębokości, aby uwierzył, że skybox ma maksymalną wartość głębi równą 1.0, tak, że nie przejdzie testu głębokości wszędzie tam, gdzie przed nim znajduje się inny obiekt.

W tutorialu układy współrzędnych powiedzieliśmy, że dzielenie perspektywiczne jest wykonywane po uruchomieniu Vertex Shader’a, dzieląc współrzędne xyz wektora gl_Position przez jego komponent w. Wiemy również z tutoriala test głębokości, że składnik z wynikowego wektora jest równy wartości głębi tego wierzchołka. Używając tych informacji możemy ustawić składnik z pozycji wyjściowej równy jego komponentowi w, co spowoduje, że składnik z będzie zawsze równy 1.0, ponieważ po zastosowaniu podziału perspektywicznego do komponentu z przekłada się to na takie równanie w / w = 1.0:

    void main()
    {
        TexCoords = aPos;
        vec4 pos = projection * view * vec4(aPos, 1.0);
        gl_Position = pos.xyww;
    }  

Wynikowe znormalizowane współrzędne urządzenia będą zawsze mieć wartość z równą 1.0: maksymalna wartość głębi. W efekcie skybox będzie renderowany wszędzie tam, gdzie nie ma widocznych obiektów (dopiero wtedy przejdzie test głębokości, wszystko inne znajdzie się przed skyboxem).

Musimy nieco zmienić funkcję głębi, ustawiając ją na GL_LEQUAL zamiast domyślnego GL_LESS. Bufor głębokości zostanie wypełniony wartościami 1.0 dla skybox’a, więc musimy upewnić się, że skybox przechodzi testy głębokości z wartościami niższymi lub równymi od wartości bufora głębi.

Możesz znaleźć bardziej zoptymalizowaną wersję kodu źródłowego tutaj.

Mapowanie środowiskowe

Teraz całe otoczenie jest odwzorowane na pojedynczym obiekcie tekstury. Możemy wykorzystać te informacje do czegoś więcej niż tylko skybox. Korzystając z cube mapy z otoczeniem, możemy nadać obiektom właściwości odbijające światło lub refrakcyjne. Techniki wykorzystujące cube mapę środowiska nazywa się mapowaniem środowiskowym, a dwie najbardziej popularne to odbicie światła i refrakcja.

Odbicie światła

Odbicie światła jest właściwością, którą obiekt (lub część obiektu) odzwierciedla otaczające środowisko, np. kolory obiektu są mniej więcej takie jak jego otoczenie w oparciu o kąt patrzenia widza. Lustro na przykład jest obiektem odbijającym światło: odzwierciedla jego otoczenie w oparciu o kąt patrzenia widza.

Podstawy odbicia światła nie są trudne. Poniższy obrazek pokazuje, jak obliczyć wektor odbicia i użyć tego wektora do próbkowania cube mapy:

Jak obliczyć odbicie.

Obliczamy wektor odbicia $\color{green}{\bar{R}}$ wokół wektora normalnego obiektu $\color{red}{\bar{N}}$ na podstawie wektora kierunku patrzenia widza $\color{gray}{\bar{I}}$. Możemy obliczyć ten wektor odbicia za pomocą wbudowanej funkcji GLSL reflect. Otrzymany wektor $\color{green}{\bar{R}}$ jest następnie używany jako wektor kierunku do indeksowania/próbkowania cube mapy, aby zwrócić wartość koloru środowiska. Wynikający z tego efekt jest taki, że obiekt wydaje się odzwierciedlać skybox.

Ponieważ mamy już skonfigurowany skybox na naszej scenie, tworzenie odbić nie jest zbyt trudne. Zmienimy Fragment Shader używany przez kontener, aby nadać mu właściwości odbijania światła:

    #version 330 core
    out vec4 FragColor;

    in vec3 Normal;
    in vec3 Position;

    uniform vec3 cameraPos;
    uniform samplerCube skybox;

    void main()
    {             
        vec3 I = normalize(Position - cameraPos);
        vec3 R = reflect(I, normalize(Normal));
        FragColor = vec4(texture(skybox, R).rgb, 1.0);
    }

Najpierw obliczamy wektor kierunku kamery I i użyć go do obliczenia wektora odbicia R, który następnie wykorzystamy do spróbkowania skyboxa. Zauważ, że mamy interpolowaną zmienną Normal i Position dla danego fragmentu, więc musimy również zmienić Vertex Shader.

    #version 330 core
    layout (location = 0) in vec3 aPos;
    layout (location = 1) in vec3 aNormal;

    out vec3 Normal;
    out vec3 Position;

    uniform mat4 model;
    uniform mat4 view;
    uniform mat4 projection;

    void main()
    {
        Normal = mat3(transpose(inverse(model))) * aNormal;
        Position = vec3(model * vec4(aPos, 1.0));
        gl_Position = projection * view * model * vec4(aPos, 1.0);
    }  

Używamy wektorów normalnych, więc będziemy chcieli je ponownie przekształcić za pomocą macierzy normalnych. Wektor wyjściowy Position jest wektorem pozycji w przestrzeni świata. Wyjściowa wartość Vertex Shader’a Position służy do obliczania wektora kierunku widoku w Fragment Shaderze.

Ponieważ używamy wektorów normalnych, będziesz chciał zaktualizować dane wierzchołków i zaktualizować wskaźniki atrybutów. Pamiętaj także, aby ustawić zmienną uniform cameraPos.

Następnie chcemy również powiązać teksturę cube mapy przed wyrenderowaniem kontenera:

    glBindVertexArray(cubeVAO);
    glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);  		
    glDrawArrays(GL_TRIANGLES, 0, 36);	  

Skompilowanie i uruchamianie kodu daje kontener, który działa jak idealne lustro. Otaczający skybox idealnie odbija się na powierzchni kontenera:

Obraz sześcianu odbijającego skybox poprzez mapowanie środowiska.

Możesz znaleźć pełny kod źródłowy tutaj.

Gdy odbicie zostanie zastosowane do całego obiektu (np. pojemnika), obiekt wygląda tak, jakby miał materiał o wysokim współczynniku odbicia, taki jak stal lub chrom. Gdybyśmy mieli załadować model nanosuit, którego użyliśmy w tutorialach o ładowaniu modeli, uzyskalibyśmy efekt, że kombinezon wygląda na wykonany w całości z chromu:

Obraz nanokombinezonu Crysis odzwierciedlającego skybox za pomocą mapowania środowiskowego.

To wygląda całkiem nieźle, ale w rzeczywistości większość modeli nie odbija światła w całości. Możemy na przykład wprowadzić mapy odbicia (ang. reflection maps), które dodają modelom dodatkowego poziomu szczegółowości. Podobnie jak mapy diffuse i specular, mapy odbicia są obrazami tekstury, które możemy próbkować w celu określenia współczynnika odbicia fragmentu. Korzystając z tych map odbicia możemy określić, które części modelu będą odbijać światło i z jaką intensywnością. W ćwiczeniach do tego samouczka jest zadanie na wprowadzenie map odbić we wcześniej utworzonej klasie Model, co znacznie zwiększy szczegółowość modelu nanokombinezonu.

Refrakcja

Inną formą mapowania środowiskowego jest refrakcja i jest ona podobna do odbicia światła. Refrakcja jest zmianą kierunku światła ze względu na zmianę materiału, przez który przepływa światło. Refrakcja jest tym, co zwykle obserwujemy na powierzchniach podobnych do wody, gdzie światło lekko się zagina. To tak, jakby patrzeć na swoje ramię, gdy jest w połowie włożone do wody.

Refrakcja jest opisana przez prawo Snella, które w zastosowaniu z mapami środowiska wygląda mniej więcej tak:

Obraz wyjaśniający załamanie światła w celu użycia go z cube mapami

Ponownie mamy wektor kierunku patrzenia $\color{gray}{\bar{I}}$, wektor normalny $\color{red}{\bar{N}}$ i tym razem wynikowy wektor refrakcji $\color{green}{\bar{R}}$. Jak widać, kierunek wektora widoku jest lekko załamany. Ten załamany wektor $\color{green}{\bar{R}}$ jest następnie używany do próbkowania cube mapy.

Refrakcję można łatwo zaimplementować za pomocą wbudowanej funkcji GLSL refract, która oczekuje wektora normalnego, kierunku patrzenia i stosunku między współczynnikami refrakcji obu materiałów.

Współczynnik załamania światła określa ilość zniekształcającego/załamanego światła. Każdy materiał ma swój własny współczynnik załamania światła. Listę najczęstszych współczynników załamania światła podano w poniższej tabeli:

Materiał Współczynnik załamania światła
Powietrze 1.00
Woda 1.33
Lód 1.309
Szkło 1.52
Diament 2.42

Korzystamy z tych współczynników załamania światła, aby obliczyć stosunek między obydwoma materiałami, przez które przechodzi światło. W naszym przypadku promień światła przechodzi z powietrza do szkła (jeśli założymy, że pojemnik jest wykonany ze szkła), więc stosunek równa się $\frac{1.00}{1.52} = 0.658$.

Mamy już powiązaną cube mapę, dostarczyliśmy dane wierzchołków z wektorami normalnymi i ustawiliśmy uniform pozycji kamery. Jedyną rzeczą, którą musimy zmienić jest Fragment Shader:

    void main()
    {             
        float ratio = 1.00 / 1.52;
        vec3 I = normalize(Position - cameraPos);
        vec3 R = refract(I, normalize(Normal), ratio);
        FragColor = vec4(texture(skybox, R).rgb, 1.0);
    }  

Zmieniając współczynniki załamania światła, można uzyskać zupełnie inne efekty wizualne. Skompilowanie aplikacji i jej uruchomienie nie daje interesujących wyników, ponieważ używamy prostego kontenera, który tak naprawdę nie pokazuje efektu refrakcji, i że teraz działa jak szkło powiększające. Użycie tych samych shaderów dla modelu nanokombinezonu pokazuje nam jednak efekt, którego szukamy: obiekt przypominający szkło.

Obraz cube mapy z wykorzystaniem refrakcji w OpenGL

Możesz sobie wyobrazić, że dzięki odpowiedniej kombinacji oświetlenia, odbicia światła, refrakcji i ruchu wierzchołków możesz stworzyć całkiem schludną grafikę wody. Zwróć uwagę, że dla uzyskania dokładnych wyników fizycznych powinniśmy ponownie załamać światło, gdy opuszcza ono obiekt; teraz po prostu używaliśmy refrakcji jednostronnej, która jest w wystarczająca dla większości celów.

Dynamiczne mapy środowiskowe

W tej chwili używaliśmy statycznej kombinacji obrazów jako skybox, który wygląda świetnie, ale nie zawiera rzeczywistej sceny z potencjalnie poruszającymi się obiektami. Tak naprawdę tego nie zauważyliśmy, ponieważ używaliśmy tylko jednego obiektu. Gdybyśmy mieli lustrzane obiekty z wieloma otaczającymi obiektami, tylko skybox byłby widoczny w lustrze, tak jakby był jedynym obiektem na scenie.

Używając framebufferów możliwe jest stworzenie tekstury sceny dla wszystkich 6 różnych kątów widzenia z obiektu, o który ma odbijać otoczenie, i zapisywanie ich w cube mapie po każdej iteracji. Następnie możemy użyć tej (dynamicznie wygenerowanej) cube mapy, aby stworzyć realistycznie wyglądające odbicie i refrakcję światła, które obejmują wszystkie inne obiekty na scenie. Jest to nazywane dynamicznym mapowaniem środowiskowym, ponieważ dynamicznie tworzymy cube mapę obiektów z otoczenia i używamy jej jako mapy środowiska.

Choć wygląda to świetnie, ma jedną ogromną wadę: musimy renderować scenę 6 razy na obiekt przy użyciu cube mapy, co jest ogromnym kosztem obliczeniowym dla aplikacji. Nowoczesne aplikacje starają się jak najlepiej wykorzystać skybox i tam, gdzie to możliwe, wstępnie prekompilować mapy, gdzie to tylko możliwe, by tworzyć dynamiczne mapy środowiskowe. Mapowanie dynamiczne jest świetną techniką, jednak wymaga wielu sprytnych sztuczek i hacków, aby działało to dobrze w rzeczywistej aplikacji bez zbyt dużego spadku wydajności.

Ćwiczenia

  • Spróbuj wprowadzić mapy odbić w klasie Modelu, którą stworzyliśmy w tutorialach o ładowaniu modeli. Możesz znaleźć ulepszony model nanokombinezonu z dołączonymi mapami odbić tutaj. Jest jednak kilka rzeczy, na które należy uważać:
    • Assimp naprawdę nie lubi map odbić w większości formatów modeli 3D, więc trochę oszukałem, przechowując mapy odbić jako mapy otoczenia. Można załadować mapy odbić, ustawiając aiTextureType_AMBIENT jako typ tekstury podczas ładowania materiałów.
    • Trochę pospiesznie stworzyłem teksturę odbić wzorując się na mapach specular, więc mapy odbić nie będą dokładnie dopasowywać się do modelu w niektórych miejscach :).
    • Ponieważ sam klasa ładowania modeli wykorzystuje już 3 jednostki tekstur w Fragment Shader, musisz powiązać skybox z czwartą jednostką teksturowania, ponieważ będziemy także próbkować skybox w tym samym Fragment Shaderze.
  • Jeśli zrobiłeś wszystko dobrze, to efekt powinien wyglądać tak.