눈팅하는 게임개발자 블로그
OpenGL Advanced 5-6 Cubemaps 본문
원문 사이트
learnopengl.com/Advanced-OpenGL/Cubemaps
번역 사이트
Cubemaps
지금까지는 2D 텍스처를 주로 사용했었는데, 물론 2D 텍스처 이외에도 여러 텍스처 유형들이 존재한다.
이번에는 여러 텍스처들을 하나의 텍스처로 매핑한 cubemaps에 대해 알아보자.
Cubemap은 기본적으로 큐브의 각 면을 형성하는 2D 텍스처들을 포함하고 있는 텍스처이다.
이런 cubemap은 유용한 특성을 가지고 있다.
방향 벡터를 사용하여 인덱싱/샘플링될 수 있다는 점이다.
중앙에 위치한 방향벡터의 원점과 1x1x1단위의 큐브가 존재한다면,
다음과 같이 텍스처를 샘플링할 수 있다.
이런 cubemap을 첨부한 큐브 도형이 존재한다면 이 cubemap을 샘플링하는 방향 벡터는
cube의 보간된 vertex위치와 비슷하다.
이런 방법으로 큐브의 실제 위치 벡터들을 사용하여 cubemap을 샘플링할 수 있다.
Creating a Cubemap
Cubemap은 다른 텍스처들과 같은 텍스처이므로 생성하기 위해서 텍스처 연산을 실행하기 전에
텍스처를 생성하고 적절한 텍스처 타겟에 바인딩해야 한다.
이를 위해 GL_TEXTURE_CUBE_MAP에 바인딩한다.
unsigned int textureID;
glGenTextures(1, &textureID);
glBindTexture(GL_TEXTURE_CUBE_MAP, textureID);
Cubemap은 6개의 텍스처로 이루어져 있기 때문에 glTexImage2D함수를 6번 호출해야 한다.
이 함수는 2D 텍스처 함수를 호출할 때와 동일하게 사용되지만 텍스처 타겟 파라미터에 cubemap의 특정 면을 설정한다.
기본적으로 OpenGL에게 생성할 해당 텍스처가 cubemap의 어떤 면에 해당되는지를 알려주는 것이다.
6개의 면을 가지고 있기 때문에 OpenGL은 cubemap의 면들을 타겟팅할 수 있도록
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 // 앞
다른 enum 변수들과 마찬가지로 점점 연속적으로 증가하는 int형 변수이므로
텍스처의 vector배열을 가지고 있다면 해당 변수들을 1씩 증가시켜 가면서 효율적으로
모든 텍스처 타겟들에 접근할 수 있다.
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
);
}
해당 코드에서는 textures_faces라는 이름을 가진 vector를 가지고 있는데
이는 cubemap을 위한 모든 텍스처들의 위치를 위의 순서대로 가지고 있다.
현재 바인딩 된 cubemap의 각 면에 텍스처를 생성한다.
cubemap은 다른 텍스처와 다를 것이 없는 텍스처이기 때문에 wrapping, filtering method를 지정할 수 있다.
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);
다음 이 cubemap을 사용할 오브젝트를 그리기 전에 해당 텍스처 유닛을 활성화하고 렌더링하기 전에
cubemap을 바인딩한다. 2D 텍스처를 바인딩 할 때와 별반 다를 것이 없다.
Fragment shader 내부에서 다른 샘플러 타입인 samplerCube를 사용해야 한다.
이 타입은 texture 함수를 사용하여 샘플링하는 것은 동일하지만 vec2 대신에 vec3의 방향 벡터를 사용한다.
cubemap을 사용하는 fragment shader의 예는 다음과 같다.
in vec3 textureDir; // 3D 텍스처 좌표를 나타내는 방향 벡터
uniform samplerCube cubemap; // Cubemap 텍스처 샘플러
void main()
{
FragColor = texture(cubemap, textureDir);
}
이제 이 cubemap을 사용하여 구현할 수 있는 흥미로운 것들을 살펴보자.
그 중 하나는 skybox를 만드는 것이다.
Skybox
Skybox는 전체 scene을 둘러싸고 주변 환경 6개의 이미지를 가지고 있는 큰 큐브이다.
플레이어가 실제로 그 환경 안에 있는 듯한 착각을 하게 만든다.
비디오 게임에서 skybox는 산, 구름, 별이 빛나는 밤하늘 이미지 같은 것들이다.
Loading a Skybox
Skybox는 그 자체로 단지 cubemap이기 때문에 skybox를 불러오는 것은 cubemap을 불러오는 것과 크게
다르지 않다. skybox를 불러오기 위해 6개의 텍스처 위치를 가지고 있는 vector를 받아들이는 다음과
같은 함수를 사용한다.
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;
}
해당 함수는 기본적으로 cubemap을 불러오기 위한 코드들을 하나의 함수로 합친 것이다.
그 다음 해당 함수를 호출하기 전에 적절한 텍스처 경로를 vector 컨테이너로 정리한다.
vector<std::string> faces;
{
"right.jpg",
"left.jpg",
"top.jpg",
"bottom.jpg",
"front.jpg",
"back.jpg"
};
unsigned int cubemapTexture = loadCubemap(faces);
이제 skybox를 cubemap으로서 불러오고 cubemapTexture에 id를 저장했다.
이제 이를 큐브에 바인딩할 수 있고 언제든 배경으로서 사용할 수 있다.
Displaying a Skybox
Skybox는 cube위에 그려지기 때문에 또 다른 VAO, VBO가 필요하고 다른 오브젝트들과 마찬가지로
vertex 세트가 필요하다.
3D 큐브의 텍스처로 사용되는 cubemap은 큐브의 위치를 텍스처 좌표로 사용하여 샘플링될 수 있다.
큐브가 원점(0, 0, 0)에 위치해 있을 때 각 위치 벡터들은 원점으로부터의 방향 벡터와 동일하다.
이 방향 벡터는 정확히 해당 텍스처 값을 얻기 위해 필요한 것이다.
이런 이유로 텍스처 좌표는 필요가 없고 오직 위치 벡터만을 제공해주면 된다.
skybox를 렌더링하기 위해 복잡하지 않은 새로운 shader 세트가 필요하다.
오직 하나의 vertex attribute만을 사용하므로 vertex shader는 간단하다.
#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);
}
입력받은 위치 벡터를 fragment shader로 보낼 텍스처 좌표로 출력한다.
그러면 fragment shader는 이를 입력받아 samplerCube를 샘플링한다.
#version 330 core
out vec4 FragColor;
in vec3 TexCoords;
uniform samplerCube skybox;
void main()
{
FragColor = texture(skybox, TexCoords);
}
이 fragment shader는 비교적 간단하다.
vertex attribute의 위치 벡터를 텍스처의 방향 벡터로 취하고 이것들을 cubemap으로부터 텍스처 값을
샘플링하기 위해 사용한다.
cubemap 텍스처를 가지고 있으므로 skybox 렌더링은 쉽다.
간단히 cubemap 텍스처를 바인딩하면 이 skybox sampler는 자동적으로 skybox cubemap으로 채워지게 된다.
이 skybox를 그리기 위해 가장 먼저 scnen에 skybox를 그릴 것이고 depth 작성을 비활성화한다.
이렇게 하면 이 skybox는 항상 모든 오브젝트들의 뒤에 그려지게 된다.
glDepthMask(GL_FALSE);
skyboxShader.use();
// ... view, projection 행렬 설정
glBindVertexArray(skyboxVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, cubemapTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
glDepthMask(GL_TRUE);
// ... scene의 나머지 그리기
이 코드를 그대로 실행해본다면 이상한 점을 발견할 수 있다.
플레이어를 중앙으로 둘러싼 skybox를 원하므로 플레이어가 얼마나 움직였는가는 신경쓰이지 않아야 한다.
하지만 현재 view 행렬은 skybox의 모든 위치들을 회전시키고 확대, 이동시키므로 플레이어가 움직이면
cubemap도 같이 움직인다.
view 행렬의 이동 부분을 지워야 움직임이 skybox의 위치 벡터에 영향을 주지 않는다.
지난 번에 4x4 변환 행렬의 좌측 상단 3x3 행렬을 취하면 이동 부분을 없앨 수 있다고 언급하였다.
이를 간단히 view 행렬을 3x3 행렬로 변환한 후 다시 4x4 행렬로 변환함으로써 이를 수행할 수 있다.
glm::mat4 view = glm::mat4(glm::mat3(camera.GetViewMatrix()));
이는 어떤 이동이든 없애준다.
하지만 모든 회전 변환은 유지하므로 플레이어는 여전히 scene을 둘러볼 수 있따.
scene을 둘러보면 scene의 현실적임이 극적으로 증가했음을 알 수 있다. 결과는 다음과 같다.
An optimization
지금 상태에서는 다른 모든 오브젝트를 그리기 전에 맨 처음 skybox를 먼저 렌더링한다.
이는 잘 동작하지만 효율적이지는 않다.
skybox를 맨 처음에 그리면 skybox의 보이는 부분이 별로 없을지라도
fragment shader를 화면의 각 픽셀들마다 실행해야 한다.
그러므로 성능 향상을 위해서 skybox를 마지막에 렌더링할 필요가 있다.
그러면 depth buffer는 완전히 다른 오브젝트들의 depth 값으로 채워지므로 오직 early depth test를
통과한 skybox의 fragment 들만 렌더링하면 된다.
이는 비약적으로 fragment shader의 호출 횟수를 줄일 수 있다.
문제는 skybox는 대부분 렌더링에 실패할 것이라는 점이다.
그저 1x1x1의 큐브이기 때문이다.
그러면 skybox가 모든 다른 오브젝트들을 덮어씌울 것이기 때문에
단순히 depth testing 없이 렌더링하는 것은 해법이 아니다.
depth buffer에 트릭을 사용해서 skybox가 depth값을 최댓값인 1.0을 가지고 있다고 믿게 만들어서
앞에 다른 오브젝트들이 있는 곳은 test에 실패하도록 만들어야 한다.
좌표 시스템에서 perspective division이 vertex shader가 실행된 후에
gl_Position의 xyz 좌표를 w요소로 나눔으로써 수행된다고 언급했었다.
또한 depth testing에서 나눗셈의 결과 z 요소는 vertex의 depth값과 동일하다고 언급했었다.
이 정보를 사용하여 출력 위치의 z 요소를 w요소와 동일하게 설정하여 z값이 항상 1.0이 될 수 있도록 만들 수 있다.
perspective division이 수행될 때 z요소는 w/w = 1.0으로 변환되기 때문이다.
void main()
{
TexCoords = aPos;
vec4 pos = projection * view * vec4(aPos, 1.0);
gl_Position = pos.xyww;
}
결과 NDC 좌표는 1.0의 z값을 가지게 된다.
이는 depth 값의 최댓값이다. 이 skybox는 결과적으로 오직 다른 오브젝트들이 없는 곳에서만 렌더링되게 된다.
이 depth 함수를 기본값인 GL_LESS 대신에 GL_LEQUAL로 설정해야 한다.
depth buffer는 skybox에 대해 1.0값으로 채워지므로 skybox를 통과하게 만들기 위해 less than이 아닌
less than or equal로 수정해야 한다.
Environment Mapping
이제 하나의 텍스처가 매핑된 한경 오브젝트를 가지고 있고 이를 skybox 이상의 것들에 대해 사용할 수 있다.
환경과 cubemap을 사용하여 오브젝트에 빛을 반사 혹은 굴절시키는 특성을 줄 수 있따.
이렇게 환경 cubemap을 사용하는 기술을 denvironment mapping 기술이라고 하고
가장 많이 사용되는 것이 reflection(반사)과 refraction(굴절)이다.
Reflection(반사)
Reflection은 오브젝트가 주변 환경을 반사하는 특성이다.
시점의 각도를 기반으로 오브젝트의 컬러들은 환경과 동일하게 설정될 수 있다.
예를 들어 거울은 반사하는 오브젝트이다. 시점의 각도에 따라 주변을 반사시킨다.
Reflection의 기본은 그리 어렵지 않다.
다음 이미지는 반사 벡터를 계산하는 방법과 cubemap을 샘플링하기 위해 이 벡터를 사용하는 방법을 보여준다.
View 방향 벡터를 기반으로 오브젝트의 법선 벡터 N에 따른 반사 벡터 R을 계산한다.
GLSL의 reflect 함수를 사용하여 이 반사 벡터를 계산할 수 있다.
결과 벡터 R은 cubemap을 인덱싱/샘플링하기 위한 방향 벡터로서 사용된다.
최종 효과는 오브젝트가 skybox를 반사하는 것처럼 보인다.
이미 scene에 skybox를 가지고 있기 때문에 reflection을 생성하는 것은 어렵지 않은 일이다.
컨테이너에 반사 속성을 주기 위해 컨테이너에 사용된 fragment shader를 수정한다.
#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);
}
먼저 view/camera 방향 벡터 I를 계산하고 반사 벡터 R을 계산하기 위해 사용된다.
이 반사 벡터는 skybox cubemap을 샘플링하기 위해 사용될 것이다.
fragment의 보간된 Normal과 Position 변수를 가지고 있으므로 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);
}
법선 벡터를 사용하므로 이들을 단위 벡터로 변환한다.
Position 출력 벡터는 world-space 위치 벡터이다.
이 Position 출력은 fragment shader에서 view 방향 벡터를 계산하기 위해 쓰인다.
법선을 사용하기 때문에 vertex data를 수정하고 attribute pointer 또한 수정해주어야 한다.
또한 cameraPos uniform도 설정해주어야 한다.
그 다음 컨테이너를 렌더링하기 전에 cubemap 텍스처를 바인딩해야 한다.
glBindVertexArray(cubeVAO);
glBindTexture(GL_TEXTURE_CUBE_MAP, skyboxTexture);
glDrawArrays(GL_TRIANGLES, 0, 36);
컴파일 후 코드를 실행해보면 완벽한 거울같은 컨테이너를 볼 수 있다.
반사가 전체 오브젝트에 적용될 때 이 오브젝트는 스틸이나 크롬같은 높은 반사율을 가진
material 오브젝트처럼 보인다.
model 불러오기에서 사용했던 nanosuit 모델을 불러온다면 전체적으로 크롬으로 이루어진듯한 효과를볼 수 있다.
이는 꽤 멋지지만, 현실에서 대부분의 모델들은 완전한 반사를 하지 않는다.
예를 들어 모델에 또다른 추가 디테일을 주는 reflection maps가 있다.
diffuse, specular map들 처럼 reflection map은 fragment의 반사율을 결정하기 위해
샘플링할 수 있는 텍스처 이미지이다.
이 reflection map을 이용하여 모델의 어느 부분이 어떠한 세기를 가진 반사율을 보여줄지 결정할 수 있다.
Refraction(굴절)
환경 매핑의 또 다른 형태는 refraction(굴절)이라고 불리고 반사와 비슷하다.
굴절은 material의 변화에 따라 빛의 방향이 달라지는 것을 말한다.
굴절은 흔히 빛이 직선으로 통과하지 않고 휘어지는 물과 같은 표면에서 볼 수 있다.
여기서 현재 view 벡터 I, 법선 벡터 N, 그리고 굴절 벡터 R을 가지고 있는데,
보다시피 view 벡터의 방향은 약간 휘어진다. 이 휘어진 벡터 R은 cubemap을 샘플링한다.
굴절은 GLSL의 refract 함수를 통해 쉽게 구현될 수 있다.
이 함수는 법선 벡터와 view 벡터의 방향 그리고 refractive indices 사이의 비율을 인자로 받는다.
굴절 index는 material의 빛이 왜곡/휘어지는 정도를 결정한다.
각 material들은 자신만의 고유한 refractive index를 가지고 있다.
가장 많이 쓰이는 refractive index들은 다음과 같다.
빛이 통과하는 두 material 사이의 비율을 계산하기 위해 이 refractive index들을 사용한다.
현재의 경우엔 빛/view 광선이 공기에서 유리로 향한다(컨테이너가 유리로 만들어져 있다고 가정한다면)
그래서 이 비율은 1.00 : 1.52 = 0.658이다.
이미 cubemap이 바인딩되어 있고 법선과 함께 vertex data도 가지고 있고 uniform으로 camera 위치도 설정했다.
남은 일은 오직 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);
}
이 refractive index들을 바꾸면 완전히 다른 비주얼 결과를 만들 수 있다.
컴파일 후 프로그램을 실행시켜 보면 기본적인 컨테이너이기 때문에 흥미로운 결과를 볼 수 없지만.
동일한 shader를 다른 모델에 적용해보면 유리같은 오브젝트처럼 보일 것이다.
'공부한거 > OpenGL' 카테고리의 다른 글
OpenGL Model Loading 4-1 Assimp (0) | 2020.12.07 |
---|---|
OpenGL Lighting 3-6 Multiple Lights (0) | 2020.12.06 |
OpenGL Lighting 3-5 Light casters (0) | 2020.11.04 |
OpenGL Lighting 3-4 Lighting maps (0) | 2020.10.22 |
OpenGL Lighting 3-3 Materials (0) | 2020.10.22 |