Skip to content

큐브맵

우리는 2D 텍스처를 꽤 오랫동안 사용해 왔지만, 아직 살펴보지 않은 텍스처 유형이 더 있습니다. 이 장에서는 여러 텍스처를 하나로 매핑한 조합인 큐브맵이라는 텍스처 유형에 대해 알아보겠습니다.

큐브맵은 각각 정육면체의 한 면을 이루는 6개의 개별 2D 텍스처를 포함하는 텍스처입니다. 즉, 텍스처가 적용된 정육면체인 셈이죠. 이런 정육면체가 왜 필요한지 궁금하실 수도 있습니다. 6개의 개별 텍스처를 그냥 사용하는 대신 왜 하나의 엔티티로 결합하는 걸까요? 큐브맵은 방향 벡터를 사용하여 인덱싱/샘플링할 수 있다는 유용한 속성을 가지고 있습니다. 중심을 방향 벡터의 원점으로 하는 1x1x1 크기의 정육면체가 있다고 상상해 보세요. 주황색 ​​방향 벡터를 사용하여 큐브맵에서 텍스처 값을 샘플링하는 것은 다음과 같습니다.

방향 벡터의 크기는 중요하지 않습니다. 방향만 제공되면 OpenGL은 해당 방향이 만나는 텍셀을 (결국) 검색하여 올바르게 샘플링된 텍스처 값을 반환합니다.

큐브맵을 적용한 정육면체 모양이 있다고 가정해 봅시다. 이 방향 벡터는 정육면체의 (보간된) 로컬 정점 위치와 유사합니다. 이처럼 정육면체가 원점을 중심으로 위치하는 한, 정육면체의 실제 위치 벡터를 사용하여 큐브맵을 샘플링할 수 있습니다. 따라서 큐브맵을 샘플링할 때 정육면체의 모든 정점 위치를 텍스처 좌표로 간주합니다. 결과적으로 큐브맵의 각 면의 텍스처에 접근하는 텍스처 좌표를 얻게 됩니다.

샘플링이란, 텍스처에서 색상을 가져오는 것을 뜻합니다. 그러니까, 큐브맵을 사용하면 벡터를 사용해서 텍스처를 샘플링 할 수 있습니다.

큐브맵 생성

큐브맵은 다른 텍스처와 마찬가지로 텍스처이므로, 큐브맵을 생성하려면 텍스처를 생성하고 추가적인 텍스처 작업을 수행하기 전에 적절한 텍스처 대상에 바인딩해야 합니다. 이번에는 GL_TEXTURE_CUBE_MAP에 바인딩합니다.

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

큐브맵은 각 면에 하나씩, 총 6개의 텍스처를 포함하므로 이전 장에서와 마찬가지로 매개변수를 설정하여 glTexImage2D 함수를 6번 호출해야 합니다. 하지만 이번에는 텍스처 대상 매개변수를 큐브맵의 특정 면과 일치하도록 설정해야 합니다. 즉, 큐브맵의 어느 면에 대한 텍스처를 생성할지 OpenGL에 알려주는 것입니다. 따라서 큐브맵의 각 면에 대해 glTexImage2D 함수를 한 번씩 호출해야 합니다.

큐브맵에는 6개의 면이 있으므로 OpenGL은 큐브맵의 각 면을 대상으로 지정할 수 있는 6개의 특수 텍스처 타겟을 제공합니다.

텍스처 타겟 방향
GL_TEXTURE_CUBE_MAP_POSITIVE_X 오른쪽
GL_TEXTURE_CUBE_MAP_NEGATIVE_X 왼쪽
GL_TEXTURE_CUBE_MAP_POSITIVE_Y 위
GL_TEXTURE_CUBE_MAP_NEGATIVE_Y 아래
GL_TEXTURE_CUBE_MAP_POSITIVE_Z 뒤
GL_TEXTURE_CUBE_MAP_NEGATIVE_Z 앞

OpenGL의 많은 열거형과 마찬가지로, 내부적으로 사용되는 정수 값은 선형적으로 증가합니다. 따라서 텍스처 위치 배열이나 벡터가 있는 경우, GL_TEXTURE_CUBE_MAP_POSITIVE_X부터 시작하여 각 반복마다 열거형 값을 1씩 증가시키면서 모든 텍스처 대상을 효과적으로 순회할 수 있습니다.

int width, height, nrChannels;
unsigned char *data;  
for(unsigned int 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
    );
}

여기에는 테이블에 제시된 순서대로 큐브맵에 필요한 모든 텍스처의 위치를 ​​포함하는 textures_faces라는 벡터가 있습니다. 이를 통해 현재 바인딩된 큐브맵의 각 면에 대한 텍스처가 생성됩니다.

큐브맵은 다른 텍스처와 마찬가지로 텍스처이므로 래핑 및 필터링 방법도 지정해야 합니다.

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);  

GL_TEXTURE_WRAP_R에 겁먹지 마세요. 이것은 단순히 텍스처의 R 좌표(위치의 z축과 같은 세 번째 차원)에 대한 래핑 방식을 설정하는 것입니다. 텍스처 좌표가 두 면 사이에 정확히 위치하더라도 하드웨어적인 제약으로 인해 정확한 면에 닿지 않을 수 있기 때문에, GL_CLAMP_TO_EDGE를 사용하면 OpenGL은 면 사이를 샘플링할 때마다 항상 경계 값을 반환합니다.

큐브맵을 사용할 객체를 그리기 전에 해당 텍스처 유닛을 활성화하고 렌더링 전에 큐브맵을 바인딩합니다. 일반적인 2D 텍스처와 크게 다르지 않습니다.

프래그먼트 셰이더 내부에서도 텍스처 함수를 사용하여 샘플링하는 samplerCube 타입의 다른 샘플러를 사용해야 하는데, 이때 vec2 방향 벡터 대신 vec3 방향 벡터를 사용합니다. 큐브맵을 사용하는 프래그먼트 셰이더의 예는 다음과 같습니다.

in vec3 textureDir; // 3D 텍스처 좌표를 나타내는 방향 벡터
uniform samplerCube cubemap; // 큐브맵 텍스처 샘플러

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

그것도 물론 좋긴 하지만, 굳이 그럴 필요가 있을까요? 사실 큐브맵을 사용하면 훨씬 쉽게 구현할 수 있는 흥미로운 기술들이 꽤 많습니다. 그중 하나가 스카이박스를 만드는 기술입니다.

스카이박스

스카이박스는 전체 장면을 감싸는 (커다란) 정육면체로, 주변 환경의 이미지 6개를 포함하여 플레이어에게 실제보다 훨씬 더 넓어 보이는 착각을 불러일으킵니다. 비디오 게임에서 사용되는 스카이박스의 예로는 산, 구름, 별이 빛나는 밤하늘 이미지가 있습니다. 별이 빛나는 밤하늘 이미지를 사용한 스카이박스의 예는 엘더스크롤 3의 다음 스크린샷에서 확인할 수 있습니다.

아마 이미 짐작하셨겠지만, 이런 스카이박스는 큐브맵에 완벽하게 어울립니다. 큐브맵은 6개의 면을 가지고 있고, 각 면마다 텍스처를 입혀야 하기 때문입니다. 이전 이미지에서는 여러 장의 밤하늘 이미지를 사용하여 플레이어가 실제로는 아주 작은 상자 안에 있지만 마치 광활한 우주에 있는 듯한 착각을 불러일으켰습니다.

일반적으로 온라인에는 그러한 스카이박스를 찾을 수 있는 자료가 충분히 있습니다. 이러한 스카이박스 이미지는 대개 다음과 같은 패턴을 가지고 있습니다.

이 6개의 면을 접어서 정육면체를 만들면 넓은 풍경을 시뮬레이션하는 완전히 텍스처링된 정육면체를 얻을 수 있습니다. 일부 리소스는 스카이박스를 이와 같은 형식으로 제공하는데, 이 경우 6개의 면 이미지를 수동으로 추출해야 하지만, 대부분의 경우 6개의 단일 텍스처 이미지로 제공됩니다.

이 특정 (고품질) 스카이박스는 우리가 장면에 사용할 것이며, 여기에서 다운로드할 수 있습니다.

스카이박스 불러오기

스카이박스는 그 자체로 큐브맵이기 때문에 스카이박스를 로드하는 방법은 이 장의 시작 부분에서 살펴본 것과 크게 다르지 않습니다. 스카이박스를 로드하기 위해 6개의 텍스처 위치를 벡터로 받는 다음 함수를 사용합니다.

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 tex 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;
} 

함수 자체는 그다지 놀랍지 않을 것입니다. 기본적으로 이전 섹션에서 살펴본 큐브맵 코드 전체를 하나의 다루기 쉬운 함수로 결합한 것입니다.

자, 이 함수를 호출하기 전에 큐브맵 열거형에 지정된 순서대로 적절한 텍스처 경로를 벡터에 로드하겠습니다.

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

우리는 스카이박스를 cubemapTexture라는 ID를 가진 큐브맵으로 불러왔습니다. 이제 드디어 이 스카이박스를 큐브에 바인딩하여 그동안 사용해왔던 밋밋한 투명 색상을 대체할 수 있게 되었습니다.

스카이박스 표시

스카이박스는 큐브 위에 그려지기 때문에 다른 3D 객체와 마찬가지로 새로운 VAO, VBO 및 새로운 정점 세트가 필요합니다. 정점 데이터는 여기에서 얻을 수 있습니다.

3D 큐브에 텍스처를 입히는 큐브맵은 큐브의 로컬 위치를 텍스처 좌표로 사용하여 샘플링할 수 있습니다. 큐브가 원점(0,0,0)에 중심을 두고 있을 때, 각 위치 벡터는 원점으로부터의 방향 벡터이기도 합니다. 이 방향 벡터는 해당 큐브 위치에서의 텍스처 값을 얻는 데 필요한 바로 그 값입니다. 따라서 위치 벡터만 제공하면 되고 텍스처 좌표는 필요하지 않습니다.

스카이박스를 렌더링하려면 새로 간단한 셰이더 세트 하나를 만들어야 합니다. 정점 속성이 하나뿐이므로 정점 셰이더는 매우 간단합니다.

#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);
}  

이 정점 셰이더에서 흥미로운 부분은 입력되는 로컬 위치 벡터를 프래그먼트 셰이더에서 (보간된) 출력 텍스처 좌표로 설정한다는 점입니다. 그러면 프래그먼트 셰이더는 이 값을 입력으로 받아 샘플러큐브를 샘플링합니다.

#version 330 core
out vec4 FragColor;

in vec3 TexCoords;

uniform samplerCube skybox;

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

프래그먼트 셰이더는 비교적 간단합니다. 정점 속성의 보간된 위치 벡터를 텍스처의 방향 벡터로 사용하고, 이를 이용하여 큐브맵에서 텍스처 값을 샘플링합니다.

큐브맵 텍스처가 있으므로 스카이박스 렌더링은 이제 간단합니다. 큐브맵 텍스처를 바인딩하기만 하면 스카이박스 샘플러가 스카이박스 큐브맵으로 자동으로 채워집니다. 스카이박스를 그리기 위해 장면의 첫 번째 오브젝트로 그리고, 깊이 쓰기를 비활성화하겠습니다. 이렇게 하면 유닛 큐브가 장면의 다른 오브젝트보다 작을 가능성이 높으므로 스카이박스는 항상 다른 모든 오브젝트의 배경에 그려집니다.

glDepthMask(GL_FALSE);
skyboxShader.use();
// ... 뷰 및 투영 행렬 설정
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(GL_TRUE);
// ... 나머지 장면 그리기

이 코드를 실행하면 문제가 발생할 것입니다. 우리는 스카이박스가 플레이어를 중심으로 배치되어 플레이어가 아무리 멀리 이동해도 스카이박스가 더 가까워지지 않도록 하여 주변 환경이 매우 넓게 느껴지도록 하고 싶습니다. 하지만 현재의 뷰 행렬은 스카이박스의 모든 위치를 회전, 크기 조정 및 이동시켜 변환하므로 플레이어가 움직이면 큐브맵도 함께 움직입니다! 우리는 뷰 행렬에서 이동 부분을 제거하고 회전만 스카이박스의 위치 벡터에 영향을 미치도록 하고 싶습니다.

기본적인 조명 효과 챕터에서 설명했듯이, 4x4 변환 행렬에서 좌측 상단의 3x3 행렬 취하면 변환 행렬의 변환 부분을 제거할 수 있습니다. 이를 위해서는 뷰 행렬을 3x3 행렬로 변환(변환(translation) 제거)한 다음 다시 4x4 행렬로 변환하면 됩니다.

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

이렇게 하면 모든 이동은 제거되지만 회전 변환은 모두 유지되므로 사용자는 여전히 장면을 둘러볼 수 있습니다.

그 결과, 스카이박스 덕분에 장면이 즉시 거대하게 보입니다. 기본 컨테이너 주변을 날아다니면 바로 규모감을 느낄 수 있어 장면의 사실감이 크게 향상됩니다. 결과는 다음과 같습니다.

다양한 스카이박스를 사용해 보고 장면의 분위기와 느낌에 얼마나 큰 영향을 미치는지 확인해 보세요.

최적화

현재 우리는 스카이박스를 먼저 렌더링한 후 씬의 다른 모든 오브젝트를 렌더링하고 있습니다. 이 방식은 잘 작동하지만 효율적이지 않습니다. 스카이박스를 먼저 렌더링하면 화면의 모든 픽셀에 대해 프래그먼트 셰이더가 실행되지만, 실제로 화면에 보이는 스카이박스는 극히 일부분에 불과합니다. 따라서 조기 깊이 테스트(early depth testing)를 사용하면, 실제로 보이지 않는 프래그먼트를 미리 버려 대역폭을 절약할 수 있습니다.

성능을 약간 향상시키기 위해 스카이박스를 마지막에 렌더링할 것입니다. 이렇게 하면 깊이 버퍼가 장면의 모든 깊이 값으로 완전히 채워지므로, 초기 깊이 테스트를 통과하는 부분에서만 스카이박스 프래그먼트를 렌더링하면 되어 프래그먼트 셰이더 호출 횟수가 크게 줄어듭니다. 문제는 스카이박스가 1x1x1 크기의 정육면체이기 때문에 대부분의 깊이 테스트를 통과하여 다른 모든 오브젝트 위에 렌더링될 가능성이 높다는 것입니다. 깊이 테스트 없이 스카이박스를 렌더링하는 것은 해결책이 될 수 없습니다. 스카이박스가 마지막에 렌더링되기 때문에 여전히 장면의 다른 모든 오브젝트를 덮어쓰게 되기 때문입니다. 스카이박스의 최대 깊이 값이 1.0이라고 깊이 버퍼가 인식하도록 하여 스카이박스 앞에 다른 오브젝트가 있는 경우 깊이 테스트에 실패하도록 만들어야 합니다.

좌표 공간 챕터에서 정점 셰이더가 실행된 후 gl_Position의 xyz 좌표를 w 성분으로 나누는 원근 분할이 수행된다고 설명했습니다. 또한 깊이 테스트 챕터에서 결과 분할의 z 성분이 해당 정점의 깊이 값과 같다는 것을 알았습니다. 이 정보를 사용하여 출력 위치의 z 성분을 w 성분과 같게 설정하면 원근 분할이 적용될 때 z 성분이 w / w = 1.0으로 변환되므로 az 성분이 항상 1.0이 됩니다.

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

결과적으로 정규화된 장치 좌표는 항상 az 값이 1.0(최대 깊이 값)이 됩니다. 따라서 스카이박스는 보이는 물체가 없는 영역에서만 렌더링됩니다(이 경우에만 깊이 테스트를 통과하며, 나머지는 모두 스카이박스 앞에 있습니다).

깊이 함수를 기본값인 GL_LESS 대신 GL_LEQUAL로 설정하여 약간 변경해야 합니다. 스카이박스의 깊이 버퍼는 1.0 값으로 채워지므로, 스카이박스가 깊이 버퍼 값보다 작거나 같은 값으로 깊이 테스트를 통과하도록 해야 합니다.

최적화된 버전의 소스 코드는 여기에서 찾을 수 있습니다.

환경 매핑

이제 주변 환경 전체를 하나의 텍스처 객체에 매핑할 수 있게 되었고, 이 정보를 스카이박스뿐만 아니라 다양한 용도로 활용할 수 있습니다. 환경 큐브맵을 사용하면 객체에 반사 또는 굴절 속성을 부여할 수 있습니다. 이처럼 환경 큐브맵을 사용하는 기법을 환경 매핑(environment mapping) 기법이라고 하며, 가장 널리 사용되는 두 가지 기법은 반사(reflection)와 굴절(refraction)입니다.

반사

반사란 물체(또는 물체의 일부)가 주변 환경을 반영하는 성질입니다. 예를 들어, 물체의 색상은 보는 사람의 각도에 따라 주변 환경의 색상과 거의 비슷하게 보입니다. 거울은 반사체로서, 보는 사람의 각도에 따라 주변 환경을 반사합니다.

반사의 기본 원리는 그리 어렵지 않습니다. 다음 이미지는 반사 벡터를 계산하고 해당 벡터를 사용하여 큐브맵에서 샘플링하는 방법을 보여줍니다.

객체의 법선 벡터 \(\textcolor{red}{\vec{N}}\)를 중심으로 시점 방향 벡터 \(\textcolor{gray}{\vec{I}}\)를 통해서 반사 벡터 \(\textcolor{green}{\vec{R}}\)를 계산합니다. 이 반사 벡터는 GLSL의 내장 함수인 reflect를 사용하여 계산할 수 있습니다. 계산된 벡터 \(\textcolor{green}{\vec{R}}\)는 방향 벡터로 사용되어 큐브맵을 인덱싱/샘플링하고, 환경의 색상 값을 반환합니다. 결과적으로 객체가 스카이박스를 반사하는 것처럼 보입니다.

이미 씬에 스카이박스가 설정되어 있으므로 반사를 만드는 것은 그리 어렵지 않습니다. 컨테이너가 사용하는 프래그먼트 셰이더를 수정하여 컨테이너에 반사 속성을 부여하겠습니다.

#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);
}

먼저 뷰/카메라 방향 벡터 I를 계산하고, 이를 사용하여 반사 벡터 R을 계산한 다음, 이 R을 사용하여 스카이박스 큐브맵에서 샘플링합니다. 프래그먼트의 보간된 법선 벡터와 위치 벡터가 다시 사용되므로 정점 셰이더도 조정해야 합니다.

#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 * vec4(Position, 1.0);
}  

우리는 법선 벡터를 사용하고 있으므로 다시 법선 행렬을 사용하여 변환해야 합니다. 위치 출력 벡터는 월드 공간 위치 벡터입니다. 정점 셰이더의 이 위치 출력은 프래그먼트 셰이더에서 뷰 방향 벡터를 계산하는 데 사용됩니다.

법선을 사용하기 때문에 정점 데이터와 속성 포인터를 모두 업데이트해야 합니다. 또한 cameraPos 유니폼도 설정해야 합니다.

다음으로 컨테이너를 렌더링하기 전에 큐브맵 텍스처를 바인딩하려고 합니다.

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

코드를 컴파일하고 실행하면 완벽한 거울처럼 작동하는 컨테이너가 생성됩니다. 주변의 스카이박스가 컨테이너에 완벽하게 반영됩니다.

전체 소스 코드는 여기에서 확인할 수 있습니다.

반사 효과를 컨테이너처럼 전체 물체에 적용하면 물체가 강철이나 크롬처럼 반사율이 높은 재질로 만들어진 것처럼 보입니다. 하지만 (모델 로딩 챕터에서 다룬 배낭 모델처럼) 좀 더 흥미로운 물체를 불러오면 물체 전체가 크롬으로 만들어진 것처럼 보이는 효과를 얻을 수 있습니다.

정말 멋져 보이지만, 실제로 대부분의 모델은 완전히 반사되는 것은 아닙니다. 예를 들어, 반사 맵을 도입하여 모델에 추가적인 디테일을 더할 수 있습니다. 확산 맵과 반사 맵처럼, 반사 맵은 프래그먼트의 반사율을 결정하기 위해 샘플링할 수 있는 텍스처 이미지입니다. 이러한 반사 맵을 사용하면 모델의 어느 부분이 반사되는지, 그리고 어느 정도의 강도로 반사되는지를 결정할 수 있습니다.

굴절

또 다른 형태의 환경 매핑은 굴절이라고 하며 반사와 유사합니다. 굴절은 빛이 통과하는 물질의 변화에 ​​따라 빛의 방향이 바뀌는 현상입니다. 빛이 물과 같은 표면으로 곧장 통과하지 않고 약간 휘어지는 것이 바로 굴절입니다. 마치 팔을 물에 반쯤 담갔을 때 팔이 물에 비치는 것과 같은 원리입니다.

굴절은 스넬의 법칙으로 설명되는데, 환경 맵을 사용하면 대략 다음과 같은 모습이 됩니다.

다시 말해, 우리는 시점 벡터 \(\textcolor{gray}{\vec{I}}\), 법선 벡터 \(\textcolor{red}{\vec{N}}\), 그리고 이번에는 결과인 굴절 벡터 \(\textcolor{green}{\vec{R}}\)을 가지고 있습니다. 보시다시피, 시점 벡터의 방향이 약간 휘어져 있습니다. 이렇게 휘어진 벡터 \(\textcolor{green}{\vec{R}}\)은 큐브맵에서 샘플링하는 데 사용됩니다.

굴절은 GLSL의 내장 함수인 refract를 사용하면 비교적 쉽게 구현할 수 있습니다. 이 함수는 법선 벡터, 시점 방향, 그리고 두 재료의 굴절률 비율을 매개변수로 받습니다.

굴절률은 빛이 물질 내에서 휘어지는 정도를 결정하며, 각 물질은 고유의 굴절률을 가지고 있습니다. 가장 일반적인 굴절률 목록은 다음 표에 나와 있습니다.

물질 굴절률
공기 1.00
물 1.33
얼음 1.309
유리 1.52
다이아몬드 2.42

우리는 이러한 굴절률을 이용하여 빛이 통과하는 두 물질의 굴절률 비율을 계산합니다. 예를 들어, 빛/시야 광선이 공기에서 유리(물체가 유리로 만들어졌다고 가정할 경우)로 통과하므로 비율은 \(\frac{1.00}{1.52} = 0.658\)이 됩니다.

큐브맵 바인딩, 정점 데이터와 법선 벡터 제공, 카메라 위치 설정까지 모두 완료했습니다. 이제 변경해야 할 부분은 프래그먼트 셰이더뿐입니다.

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);
}  

굴절률을 변경하면 완전히 다른 시각적 결과를 얻을 수 있습니다. 하지만 애플리케이션을 컴파일하고 컨테이너 객체에 결과를 적용하는 것은 그다지 흥미롭지 않습니다. 현재로서는 돋보기처럼 작동하는 것 외에는 굴절 효과를 제대로 보여주지 않기 때문입니다. 그러나 로드된 3D 모델에 동일한 셰이더를 적용하면 우리가 원하는 효과, 즉 유리와 같은 물체가 나타나는 것을 확인할 수 있습니다.

적절한 조명, 반사, 굴절 및 정점 이동을 조합하면 매우 멋진 물 그래픽을 만들 수 있다는 것을 상상할 수 있습니다. 물리적으로 정확한 결과를 얻으려면 빛이 물체를 빠져나갈 때 다시 굴절시켜야 한다는 점에 유의하세요. 여기서는 대부분의 경우 충분한 단측 굴절만 사용했습니다.

동적 환경 맵

현재 저희는 스카이박스로 정적인 이미지 조합을 사용하고 있는데, 보기에는 좋지만 움직이는 물체가 있는 실제 3D 장면은 포함하지 않습니다. 지금까지는 단일 물체만 사용했기 때문에 이 문제를 알아차리지 못했습니다. 하지만 거울처럼 반사되는 물체 주변에 여러 물체가 있다면, 거울에 비친 스카이박스만 보이고 마치 장면에는 그 물체 하나만 있는 것처럼 보일 것입니다.

프레임버퍼를 사용하면 대상 객체의 6가지 서로 다른 각도에서 촬영한 장면 텍스처를 생성하고 이를 매 프레임마다 큐브맵에 저장할 수 있습니다. 그런 다음 이 (동적으로 생성된) 큐브맵을 사용하여 다른 모든 객체를 포함하는 사실적인 반사 및 굴절면을 만들 수 있습니다. 이를 동적 환경 매핑이라고 하는데, 객체 주변 환경의 큐브맵을 동적으로 생성하여 해당 객체의 환경 맵으로 사용하기 때문입니다.

보기에는 훌륭하지만, 한 가지 엄청난 단점이 있습니다. 환경 맵을 사용할 경우 객체 하나당 장면을 6번씩 렌더링해야 하므로 애플리케이션 성능에 막대한 악영향을 미칩니다. 최신 애플리케이션들은 가능한 한 스카이박스를 활용하고, 가능한 경우 큐브맵을 미리 렌더링하여 동적 환경 맵을 구현하려고 합니다. 동적 환경 매핑은 훌륭한 기술이지만, 실제 렌더링 애플리케이션에서 성능 저하를 최소화하면서 구현하려면 여러 가지 영리한 기법과 편법이 필요합니다.

Comments