[OpenGL] 그림자 매핑

gest·2026년 4월 28일

OpenGL

목록 보기
2/11

최근 VR 프로젝트를 잠시 내려놓고, 분위기 환기 겸 OpenGL을 이용해 그림자 매핑을 학습했다. 아무튼 그림자 매핑에 대해 배워보자.


그림자 생기는 원리

그래픽스에서 그림자를 어떻게 구현할까? 그림자는 결국 빛이 가로막혀서 도달하지 못한 영역이다. 빛에서 광선을 쭉 뻗었을 때, 같은 방향 위에 두 점이 놓여 있으면 빛에 가까운 쪽이 먼 쪽을 가린다. 즉, 뒤에 있는 점이 그림자에 들어간다고 보면 된다.

즉 빛의 거리인 Z1, Z2처럼 Z2가 큰 경우 Z2에 그림자가 생긴다고 보면 된다. 만약 같다면 그 점은 빛이 처음 부딪힌 표면이고, 그림자는 생기지 않는다.
근데 이걸 매 픽셀마다 광선 쏴서 충돌 검사하면 컴퓨터는 과부하가 걸린다. 그래서 나온 것이 쉐도우 매핑이다.

쉐도우 매핑이란?

Shadow Mapping
빛 시점에서 씬을 한 번 렌더링하면서 "빛에서 가장 가까운 표면까지의 거리"를 텍스처에 미리 구워둔다. 색깔은 필요 없고 깊이만 저장한다. 이게 shadow map(depth map)이다.
내가 보는 화면을 기준으로 그림자를 그 화면 안에 보여준다.
내가 보고 있는 화면의 빛과의 거리를 저장한다.

  • pcfDepth는 빛 시점에서 가장 가까운 오브젝트 거리
  • currentDepth는 실제 픽셀의 빛 까지의 거리
//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;         

그러나 이러면 문제가 하나 생긴다.

무슨 여드름마냥 그림자가 생긴다.

Bias

왜 저렇게 여드름(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은 빛 시점의 해상도지 화면 해상도가 아니다.

PCF

또 하나, 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에 넣기

Shader

vs

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

vs

깊이 셰이더

그림자 깊이를 저장하는 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);
}

fs

물체 셰이더

#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 코드

그림자만 나오게 하는 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);
}

0개의 댓글