Shadow Mapping

Date:     Updated:

카테고리:

태그:

Main Reference
- Learn OpenGL
- Rinthel Kwon - OpenGL Lecture
- OGLDEV - OpenGL Tutorial


Shadow Mapping

Ect-68

Shadow Mapping은 그림자 구현에 가장 많이 사용되는 알고리듬 기법이다. 구현 난이도가 어렵지 않으며, 고급 알고리듬으로 확장하기도 용이하다. 대략적인 구현 방법은 다음과 같다.

  • Light를 기준으로 Depth Map 구하기 (Shadow Map)
  • 실제 렌더링되는 특정 픽셀의 world position과 light 사이의 거리 구하기
  • 두 값을 비교하여 그림자 영역 판별


Shadow Map - First Pass

CLASS_PTR(ShadowMap);
class ShadowMap {
public:
    static ShadowMapUPtr Create(int width, int height);
    ~ShadowMap();

    const uint32_t Get() const { return m_framebuffer; }
    void Bind() const;
    const TexturePtr GetShadowMap() const { return m_shadowMap; }

private:
    ShadowMap() {}
    bool Init(int width, int height);

    uint32_t m_framebuffer { 0 };
    TexturePtr m_shadowMap;
};
  • ShadowMap 클래스의 구조는 FrameBuffer와 유사하다.
  • m_framebuffer : shadow map을 렌더링하기 위해 사용하는 framebuffer
  • m_shadowMap : 렌더링 결과를 저장할 텍스처


bool ShadowMap::Init(int width, int height) {
    glGenFramebuffers(1, &m_framebuffer);
    Bind();

    m_shadowMap = Texture::Create(width, height, GL_DEPTH_COMPONENT, GL_FLOAT);
    m_shadowMap->SetFilter(GL_NEAREST, GL_NEAREST);
    m_shadowMap->SetWrap(GL_CLAMP_TO_BORDER, GL_CLAMP_TO_BORDER);
    m_shadowMap->SetBorderColor(glm::vec4(1.0f));

    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT,
        GL_TEXTURE_2D, m_shadowMap->Get(), 0);
    glDrawBuffer(GL_NONE);
    glReadBuffer(GL_NONE);
    auto status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
    if (status != GL_FRAMEBUFFER_COMPLETE) {
        SPDLOG_ERROR("failed to complete shadow map framebuffer: {:x}", status);
        glBindFramebuffer(GL_FRAMEBUFFER, 0);
        return false;
    }
    glBindFramebuffer(GL_FRAMEBUFFER, 0);
    return true;
}

Shadow Map 클래스의 전체적인 구조는 Frame Buffer와 유사하지만, 구체적인 구현 과정에서는 몇몇 차이가 존재한다.

  • m_shadowMap = Texture::Create(width, height, GL_DEPTH_COMPONENT, GL_FLOAT);
    • 우선 shadow map 텍스처를 생성할 때 일반적인 텍스처와 세팅 값이 다르다.
    • 단순 Depth Map 텍스처 이므로 RGBA 8bit가 아닌 단일채널 32bit flaot 으로 생성하기 때문
    • GL_RGB format에서 GL_DEPTH_COMPONENT로 변경
    • GL_UNSIGNED_BYTE type에서 GL_FLOAT으로 변경
  • glDrawBuffer(GL_NONE), glReadBuffer(GL_NONE)
    • OpenGL에게 color attachment가 없음을 명시적으로 알려줘야 함
    • 그래야 glCheckFramebufferStatus 함수 통과


// Render()..

ImGui::Image((ImTextureID)m_shadowMap->GetShadowMap()->Get(),
            ImVec2(256, 256), ImVec2(0, 1), ImVec2(1, 0));

// ...

auto lightView = glm::lookAt(m_light.position, m_light.position + m_light.direction, glm::vec3(0.0f, 1.0f, 0.0f));
auto lightProjection = glm::perspective(glm::radians((m_light.cutoff[0] + m_light.cutoff[1]) * 2.0f), 1.0f, 1.0f, 20.0f);

m_shadowMap->Bind();
glClear(GL_DEPTH_BUFFER_BIT);
glViewport(0, 0,
    m_shadowMap->GetShadowMap()->GetWidth(),
    m_shadowMap->GetShadowMap()->GetHeight());
m_simpleProgram->Use();
m_simpleProgram->SetUniform("color", glm::vec4(1.0f, 1.0f, 1.0f, 1.0f));
DrawScene(lightView, lightProjection, m_simpleProgram.get());

Framebuffer::BindToDefault();
glViewport(0, 0, m_width, m_height);
  • ImGUI를 통해 shadow map 텍스처 확인용 visualize 설정
  • Light의 입장에서 depth map을 그려야 하기 때문에 camera view, projection이 아닌 light view, projection 세팅
  • 이때 주의할 점은 shadow map의 해상도와 실제 렌더링되는 frame buffer의 해상도가 다를 수 있기 때문에 viewport 알맞게 지정


Result

045942

아직까지는 shadow map을 이용해 그림자를 구현한 것은 아니고, ImGUI를 통해 shadow map 텍스처를 시각화한 결과이기 때문에 렌더링 결과는 변하지 않는다. Shadow Map의 경우 Float32 단일 채널로 텍스처를 지정했기 때문에 depth 값이 커질수록 빨간색으로 표현된다.


Shadow Map - Second Pass

// vertex shader
void main() {
    gl_Position = transform * vec4(aPos, 1.0);
    vs_out.fragPos = vec3(modelTransform * vec4(aPos, 1.0)); // world 기준
    vs_out.normal = transpose(inverse(mat3(modelTransform))) * aNormal;
    vs_out.texCoord = aTexCoord;
    vs_out.fragPosLight = lightTransform * vec4(vs_out.fragPos, 1.0); // world 기준 fragpos를 light 기준 transform
}
  • 대부분 일반적인 vertex shader와 유사
  • ` vs_out.fragPosLight = lightTransform * vec4(vs_out.fragPos, 1.0)`
    • world 좌표인 fragpos를 light 기준 fragPos로 변환
    • lightTransform : lightProjection * lightView


// fragment shader

float ShadowCalculation(vec4 fragPosLight) {
  vec3 projCoords = fragPosLight.xyz / fragPosLight.w;
  projCoords = projCoords * 0.5 + 0.5;
  float closestDepth = texture(shadowMap, projCoords.xy).r;
  float currentDepth = projCoords.z;
  float shadow = currentDepth > closestDepth ? 1.0 : 0.0;
  return shadow;
}

void main() {
    // ...

    float shadow = ShadowCalculation(fs_in.fragPosLight);

    result += (diffuse + specular) * intensity * (1.0 - shadow);
}
  • 대부분 일반적인 fragment shader와 유사
  • ShadowCalculation 함수를 통해 그림자로 판별되는 픽셀은 0으로 설정
    • projCoords xyz 좌표 w로 나누고, [0,1] 범위로 정규화
    • closestDepth : shadow map에서 가져온 pixel~light 거리
    • currentDepth : world 상에서의 pixel~light 거리
    • currentDepth > closestDepth 이면 그림자 영역


Result

173757


Shadow Ance

위 결과물을 보면 이상한 줄무늬들이 생겨나는 것을 볼 수 있다. 이러한 현상을 shadow ance라고 하는데, 제한적인 shadow map의 해상도로 인한 부정확한 depth map이 원인이다. 이 때문에 그림자 영역이 아님에도 불구하고 그림자 처리를 하여 이상한 무늬가 생기는 것이다. 원인에 대해 조금 더 자세하게 이야기하면 아래와 같다.


Reason 1 Reason 2
201633 201659
  • 첫 번째 원인으로는 Rendering Pass에 따라 interpolation된 depth 값이 다름
    • 해상도, 서로 다른 좌표 변환으로 인해 rasterize가 미묘하게 달라지기 때문
  • 두 번째 원인으로는 카메라 변환 기준으로 서로 다른 pixel들이 shadow map 기준으로는 동일한 pixel인 경우가 존재.
    • 이러한 현상에 의한 depth 차이는 normal과 light의 각도가 클 수록 더 큰 효과로 나타남
  • 이러한 문제는 아주 미세한 depth 값 차이에 의해 발생하므로 shadow map에 작은 bias 값을 추가해주면 해결됨
  • 두 번째 현상을 해결하기 위해 bias 값은 normal과 light 사이의 각도에 비례하게 설정


// fragment shader
float ShadowCalculation(vec4 fragPosLight, vec3 normal, vec3 lightDir) {
    vec3 projCoords = fragPosLight.xyz / fragPosLight.w;
    projCoords = projCoords * 0.5 + 0.5;
    float closestDepth = texture(shadowMap, projCoords.xy).r;
    float currentDepth = projCoords.z;
    float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);
    float shadow = currentDepth - bias > closestDepth ? 1.0 : 0.0;
    return shadow;
}


Result

Before Bias After Bias
173757 175302


OverSampling

13_shadow_map_oversampling


// texture.cpp
void Texture::SetBorderColor(const glm::vec4& color) const {
  glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR,
    glm::value_ptr(color));
}

Shadow Map 방식을 이용할 때 주의해야 할 점이 있다. Shadow Map 텍스처의 해상도와 렌더링되는 프레임 버퍼의 텍스처 해상도가 다르게 되면 문제가 발생할 수 있다. 일반적으로 Shadow Map 텍스처의 해상도가 낮기 때문에 Wrapping이 되는데 GL_REPEAT 방식이로 wrapping을 하게 되면, 위 그림처럼 이상한 그림자가 생길 수 있다. 따라서 shadow map 텍스처의 wrapping 모드는 GL_CLAMP_TO_BORDER로 설정해야 한다.


Percentage Closer Filtering (PCF)

184300

Shadow Map을 이용한 그림자 렌더링의 경우 픽셀마다 그림자인지 아닌지 이분적으로 판단하게 된다. 따라서 그림자 영역의 경계 부분인 픽셀들은 0 또는 1로만 구분되기 때문에 지글지글한 그림자가 그려지게 된다. PCF 기법을 이용하면 이러한 현상을 보정할 수 있게 된다.


images


    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;
  • 특정 픽셀의 그림자를 0 또는 1로 판단하지 않음
  • 주변 픽셀들의 그림자 영역 여부를 참조하여 [0~1] 범위의 값 리턴
    • ex) 주변 포함 9개의 픽셀 중 그림자 영역인 픽셀의 몇 개인지?
  • 결과적으로 딱딱한 경계가 아닌 부드러운 경계 생성


Before PCF After PCF
184300 184727


Point-Light Shadow Map

Point Light Example
13_shadow_map_point_light_idea vsdct_s

Point Light의 경우 빛이 모든 방향으로 뻗어 나가기 때문에 일반적인 단일 텍스처로 그림자를 표현할 수 없다. 따라서 하나의 depth map을 렌더링하는 대신 depth map으로 이루어진 cube map 텍스처를 렌더링하고 이를 이용해 그림자를 구현하게 된다. 이러한 방식을 Omni-Directional Shadow Mapping 방식이라고 한다.



맨 위로 이동하기

OpenGL 카테고리 내 다른 글 보러가기

댓글 남기기