최근 VR 프로젝트를 잠시 내려놓고, 분위기 환기 겸 OpenGL을 이용해 그림자 매핑을 학습했다. 아무튼 그림자 매핑에 대해 배워보자.
그래픽스에서 그림자를 어떻게 구현할까? 그림자는 결국 빛이 가로막혀서 도달하지 못한 영역이다. 빛에서 광선을 쭉 뻗었을 때, 같은 방향 위에 두 점이 놓여 있으면 빛에 가까운 쪽이 먼 쪽을 가린다. 즉, 뒤에 있는 점이 그림자에 들어간다고 보면 된다.

즉 빛의 거리인 Z1, Z2처럼 Z2가 큰 경우 Z2에 그림자가 생긴다고 보면 된다. 만약 같다면 그 점은 빛이 처음 부딪힌 표면이고, 그림자는 생기지 않는다.
근데 이걸 매 픽셀마다 광선 쏴서 충돌 검사하면 컴퓨터는 과부하가 걸린다. 그래서 나온 것이 쉐도우 매핑이다.
Shadow Mapping
빛 시점에서 씬을 한 번 렌더링하면서 "빛에서 가장 가까운 표면까지의 거리"를 텍스처에 미리 구워둔다. 색깔은 필요 없고 깊이만 저장한다. 이게 shadow map(depth map)이다.
내가 보는 화면을 기준으로 그림자를 그 화면 안에 보여준다.
내가 보고 있는 화면의 빛과의 거리를 저장한다.

//r은 rgb의 r. 즉, 그림자 버퍼는 하나만 차지하니 R값을 가져오는 거다.
//그림자 깊이 가져오는 함수. texture(배열, 좌표)
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
//z1 > z2 그림자가 생기는 지 아닌지 거리 비교하는 함수. 위 그림에서 나온 말이다.
shadow += currentDepth > pcfDepth ? 1.0 : 0.0;
그러나 이러면 문제가 하나 생긴다.

무슨 여드름마냥 그림자가 생긴다.
왜 저렇게 여드름(shadow acne) 현상이 생길까?
원인은 shadow map이 유한 해상도 텍스처라는 데 있다. shadow map은 빛 시점을 픽셀단위로 쪼개서 저장하는데, 한 텍셀은 표면의 작은 영역을 통째로 대표한다. 그 영역 안에서는 딱 한 점의 깊이값만 저장된다.

문제는 카메라 픽셀 q를 빛 공간으로 변환했을 때 그 좌표가 텍셀 격자에 정확히 떨어지지 않는다는 것. 그래서 가져온 pcfDepth는 사실 q 바로 위 지점이 아니라, 그 근처 어딘가의 한 점의 깊이다. 그 결과 같은 평면 위에 있는 픽셀들이 같은 텍셀 하나를 참조하면서, 어떤 건 currentDepth가 살짝 크고 어떤 건 살짝 작아져 줄무늬가 생긴다.
"어? 그러면 q에서 직접 다시 재면 되지 않나?" 싶을 수 있는데, shadow map은 그 비싼 작업을 피하려고 미리 텍스처에 구워두는 트릭이라, 양자화 오차를 안고 갈 수밖에 없다.
해결책은 비교할 때 살짝 currentDepth 값을 빼주는 것이다.
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
이러면 확실히 여드름 효과가 덜 하거나 없어진다. 하지만 너무 큰 bias는 그림자를 줄이거나 없어지게 할 수 있다.
여담으로 shadow map 해상도를 헷갈려서 화면 해상도(SCR_WIDTH, SCR_HEIGHT)에 맞추는 경우가 있는데, 그럴 필요 없이 1024×1024, 2048×2048 같은 정사각형 상수로 따로 두는 게 좋다. shadow map은 빛 시점의 해상도지 화면 해상도가 아니다.
또 하나, shadow map 해상도가 유한하다 보니 그림자 경계가 픽셀 단위로 계단처럼 끊어져 보인다. 한 텍셀이 표면의 한 영역을 통째로 그림자/빛으로 판정해버리니까 가장자리가 들쭉날쭉하다.
이걸 부드럽게 하는 기법이 PCF(Percentage Closer Filtering).
원리는 단순하다. 그 한 점만 보고 0이나 1로 결정하지 말고, 주변 텍셀 여러 개를 샘플링해서 평균을 낸다. 경계 부근에서 0과 1 사이의 중간값이 나오면서 부드러운 그라데이션이 생긴다.
float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
for(int x = -1; x <= 1; ++x)
{
for(int y = -1; y <= 1; ++y)
{
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;
여기선 9로 나눴다.
먼저 깊이 정보를 가진 map이 필요하다.
//shadow map
unsigned int depthMap; // create depth texture
unsigned int depthMapFBO;
depthProcessing(depthMapFBO, depthMap);
//shadow mapping pre processing
void depthProcessing(unsigned int & depthMapFBO, unsigned int& depthMap)
{
glGenFramebuffers(1, &depthMapFBO); //프레임버퍼 1번
glGenTextures(1, &depthMap); //특수 도화지 판(FBO)
glBindTexture(GL_TEXTURE_2D, depthMap); //빈 캔버스 활성화
//깊이 전용 메모리 할당. 원래는 GL_RGB지만 깊이만 저장하니 GL_DEPTH_COMPONENT
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, SCR_WIDTH, SCR_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
//픽셀을 확대 축소할때 부드럽게 할건지(GL_LINEAR) 그대로 가져올지(GL_NEAREST)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
//혹시나 문제생기면 이거로 교체
//glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
//glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
// [수정] 맵 밖은 무조건 "그림자 없음(1.0)"으로 처리
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
float borderColor[] = { 1.0f, 1.0f, 1.0f, 1.0f };
glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, borderColor);
//FBO와
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); //프레임 버퍼로 바인드
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0); //프레임버퍼dhk ㅡ메 qnxdlrl
glDrawBuffer(GL_NONE); // 색상 버퍼 비활성화
glReadBuffer(GL_NONE); // 색상 버퍼 비활성화
glBindFramebuffer(GL_FRAMEBUFFER, 0); //초기화
}
빛을 하나의 카메라라고 생각하고, 해당 시점에서의 Projection과 View 행렬을 곱해 lightSpaceMatrix를 구한다.
ground->setShadowMap(depthMap);
while (!glfwWindowShouldClose(window))
{
// shadow projection 계산 =>
float near_plane = 1.0f, far_plane = 12.5f;
glm::mat4 lightProjection = glm::ortho(-30.0f, 30.0f, -30.0f, 30.0f, near_plane, far_plane);
glm::mat4 lightView = glm::lookAt(lightPos, glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f)); //start, end, 위 벡터 : end 아무거나 해도 다 알아서 기울기 계산해준다.
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
}
// 깊이 버퍼
depthShader.use();
depthShader.setMat4("lightSpaceMatrix", lightSpaceMatrix);
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT); // 뷰포트를 도화지 크기에 맞춤
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO); // 그림자 도화지(FBO) 장착!
glClear(GL_DEPTH_BUFFER_BIT); // 도화지 초기화
mouse->drawShadow(depthShader);
ground->drawShadow(depthShader);
// 오브젝트 render
glClearColor(0.2f, 0.3f, 0.3f, 1.0f); //sky
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); //glEnable(GL_DEPTH_TEST);를 추가하면 GL_DEPTH_BUFFER_BIT도 넣어라
for (int i = 0; i < objects.size(); i++)
{
objects[i]->drawGameObject(camera, lightColor, lightPos, lightSpaceMatrix);
}
//draw 관련 함수
void drawGameObject(Camera& camera, glm::vec3 lightColor, glm::vec3 lightPos, glm::mat4 lightSpaceMatrix)
{
shader->use();
shader->setMat4("lightSpaceMatrix", lightSpaceMatrix);
//택스쳐
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, *(this->texture)); //텍스쳐 넣기?
//todo - 여기에 그림자 map 넣기
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, shadowMap);
shader->setInt("shadowMap", 1); // "쉐이더야, 그림자는 1번에서 읽어와!"
GameObject::drawMiniGameObject(camera, lightColor, lightPos, color, glm::vec3(0.0f, 0.5f, 0.0f));
glDrawArrays(GL_TRIANGLES, 0, 6); //삼각형
}
void drawShadow(Shader& shader)
{
glBindVertexArray(vao);
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, position);
model = glm::scale(model, scale); // 땅의 크기
shader.setMat4("model", model);
// 땅은 사각형이므로 정점이 6개! (쥐의 nSphereVert와 다릅니다)
glDrawArrays(GL_TRIANGLES, 0, 6);
}
텍스쳐를 넣는 게 키 포인트.
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, shadowMap);
shader->setInt("shadowMap", 1); // 1번지 GL_TEXTURE1의 1을 sampler2D에 넣기
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;
out vec3 FragPos;
out vec3 Normal;
out vec2 TexCoord;
out vec4 FragPosLightSpace;
//입력
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
uniform mat4 lightSpaceMatrix;
void main()
{
FragPos = vec3(model * vec4(aPos, 1.0));
//노멀벡터
Normal = mat3(transpose(inverse(model))) * aNormal;
TexCoord = vec2(aTexCoord.x, aTexCoord.y);
FragPosLightSpace = lightSpaceMatrix * vec4(FragPos, 1.0);
gl_Position = projection * view * vec4(FragPos, 1.0);
}
그림자 깊이를 저장하는 vertex 셰이더
#version 330 core
layout (location = 0) in vec3 aPos;
uniform mat4 lightSpaceMatrix;
uniform mat4 model;
void main()
{
gl_Position = lightSpaceMatrix * model * vec4(aPos, 1.0);
}
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoord;
out vec3 FragPos;
out vec3 Normal;
out vec2 TexCoord;
out vec4 FragPosLightSpace;
//입력
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
uniform mat4 lightSpaceMatrix;
void main()
{
FragPos = vec3(model * vec4(aPos, 1.0));
//노멀벡터
Normal = mat3(transpose(inverse(model))) * aNormal;
TexCoord = vec2(aTexCoord.x, aTexCoord.y);
FragPosLightSpace = lightSpaceMatrix * vec4(FragPos, 1.0);
gl_Position = projection * view * vec4(FragPos, 1.0);
}
#version 330 core
out vec4 FragColor;
in vec3 Normal;
in vec3 FragPos;
in vec2 TexCoord;
in vec4 FragPosLightSpace;
// texture samplers =>
//shader.setInt로 세팅, glActiveTexture(GL_TEXTURE0);로 설정
//예를 들어 setInt0이면 glActiveTexture의 0번 참조하라는 뜻
uniform sampler2D texture1;
uniform sampler2D shadowMap;
//uniform sampler2D normalMap;
uniform vec3 lightPos;
uniform vec3 viewPos;
uniform vec3 lightColor;
float ShadowCalculation(vec4 fragPosLightSpace, vec3 lightDir, vec3 normal)
{
// perform perspective divide
vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
// transform to [0,1] range
projCoords = projCoords * 0.5 + 0.5;
// get depth of current fragment from light's perspective
float currentDepth = projCoords.z;
// get closest depth value from light's perspective (using [0,1] range fragPosLight as coords)
float closestDepth = texture(shadowMap, projCoords.xy).r; //=> 그림에서의 g값? z값?
float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
float shadow = 0.0;
vec2 texelSize = 1.0 / textureSize(shadowMap, 0);
for(int x = -1; x <= 1; ++x)
{
for(int y = -1; y <= 1; ++y)
{
float pcfDepth = texture(shadowMap, projCoords.xy + vec2(x, y) * texelSize).r;
shadow += currentDepth - bias > pcfDepth ? 1.0 : 0.0;
}
}
shadow /= 9.0;
// keep the shadow at 0.0 when outside the far_plane region of the light's frustum.
if(projCoords.z > 1.0)
shadow = 0.0;
// check whether current frag pos is in shadow
//이게 아마 pcf값?
return shadow;
}
void main()
{
vec3 color = texture(texture1, TexCoord).rgb;
// ambient
float ambientStrength = 0.3; //얼마나 밝은지
vec3 ambient = ambientStrength * lightColor;
// diffuse
vec3 norm = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float diff = max(dot(norm, lightDir), 0.0);
vec3 diffuse = diff * lightColor * color;
// specular
float specularStrength = 0.5;
vec3 viewDir = normalize(viewPos - FragPos);
vec3 reflectDir = reflect(-lightDir, norm);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 1);
vec3 specular = specularStrength * spec * lightColor;
// calculate shadow
float shadow = ShadowCalculation(FragPosLightSpace, lightDir, Normal); //쉐도우가 오면 1, 안 오면 0
vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color; //여기만 다르다
FragColor = vec4(lighting, 1.0);
//FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

이렇게 그림자가 나온다.
하지만 지정된 범위를 넘어서면 그림자가 나오지 않는다.
어떻게 해야할까?
lightProjection의 매개변수 상하좌우를 키우면 된다.
glm::mat4 lightProjection = glm::ortho(상, 하, 좌, 우, near_plane, far_plane); //상하좌우 범위 값.
// shadow projection 계산
float near_plane = 1.0f, far_plane = 12.5f;
glm::mat4 lightProjection = glm::ortho(-30.0f, 30.0f, -30.0f, 30.0f, near_plane, far_plane); //상하좌우 범위 값.
glm::mat4 lightView = glm::lookAt(lightPos, glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
glm::mat4 lightSpaceMatrix = lightProjection * lightView;
그림자만 나오게 하는 fs 코드다.
void main()
{
vec3 normal = normalize(Normal);
vec3 lightDir = normalize(lightPos - FragPos);
float shadow = ShadowCalculation(FragPosLightSpace, lightDir, normal);
// 그림자: 검정(0), 빛 받는 곳: 흰색(1)
vec3 color = vec3(1.0 - shadow);
FragColor = vec4(color, 1.0);
}