Post Lists

2018년 12월 18일 화요일

Directional Light Shadow 구현기 in Deferred Shading

현재 내 Deferred Shading 엔진에 Directional Light Shadow를 구현하는데 있어서
관련된 사항들을 정리하고 싶다. 왜냐하면 이론은 간단할지라도, 디테일하게 알아야 할 부분들, 최적화 사항들이 여러가지가 있어서 정리하면 좋을 것 같다.

사실 아직 의문이 드는 것들도 있지만, 일단 할 수 있는 선에서 문제만을 알고 있기라도 해야겠다.

목차는 이렇게 될 것 같다.

1. Directional Light Shadow 구현 기본
2. Directional Light Shadow in Deferred Shadow 구현 방법
3. 구현 시 에러 사항들 (해결 된 것 + 해결 안 된 것)

1. Directional Light Shadow 구현 기본
 Shadow를 구현하는데 있어서, 현대 그래픽스에서 사용하는 기법은 Shadow Mapping이라는 기법인데, Shadow Map(Texture)를 이용해서 Depth Value를 이용해서, 어떤 지점에 그림자를 그려야할지 안할지를 결정하는 기법이다.

Shadow Map을 만드는 방법은 한 프레임버퍼에 해당 Directional Light의 Position, Direction을 고려해서, Light View와 Light Projection 행렬을 만든 후, 그것을 통해서 여러가지 Object들을 렌더링해서 Depth Values를 저장한다.  즉 간단히 말해서, 우리가 카메라를 통해서 object를 보이듯이, Light Space Matrix를 통해서, Light의 관점에서 오브젝트들의 그림을 Snapshot을 찍는 것이다.

그 Snapshot을 찍으면 Light의 위치에서, Light의 방향으로 바라보았을 때의 Depth들이 나온다. Shadow Map에 그 값들이 저장된다면, 이제 Forward rendering을 할 때, 해당 fragment를 그 Light Space로 바꾸고, 그것의 depth값을, Light가 바라보았을 때의 depth값과 비교한다. Light가 바라 보았을 때의 depth값은 0 ~ 1 사이이고, 가까울 수록 0이고, 멀수록 1이다. 그래서 Light가 바라 보았을 때의 depth가 fragment의 depth보다 더 크다면,


light depth value > fragment depth value : fragment는 light space의 depth보다 더 가까이 있으므로 그림자에 있지 않다.
light depth value < fragment depth value : fragment는 light space의 depth보다 더 멀리 있으므로 그림자에 있다.

그래서 이를 통해서 간단하게 해당 fragment가 shadow여야 하는지 아닌지를 결정하고, shading을 행한다.

2. Directional Light Shadow in Deferred Shadow 구현 방법
스택오버플로우와 여러 구글 사이트에서 효율적으로 구현하는 방법을 찾아보았으나, 아직 딱히 따로 효율적으로 구현하는 방법이 없다.

우리는 어쨋든 Shadow Cast를 하는 Light 각각에 대해 Shadow Map을 그려줘야 한다.
따라서 전반적인 Pipeline은 다음과 같아진다.

1. Shadow cast를하는 각 Light의 시점(Space)에서 Object들을 렌더링하여 Shadow Map 만들기
2. Deferred Rendering의 First Pass로 G-buffer 만들기
3. Deferred Rendering의 Second Pass
    - G-Buffer 넘겨주기
    - Light 정보 넘겨주기
    - *Shadow Cast를 하는 Light의 ShadowMap 바인딩 하기
    - *Shadow Cast를 하는 Light의 LightSpace Matrix를 넘겨주기
    - Lighting, Shadow 연산
    - Quad 그리기
4. Deferred Rendering with Forward Rendering (for debugging)

Shadow Cast를 하는 Light가 많아진다면, 그 만큼의 texture에 모든 모델들을 그려주고,
Second pass에 그 texture를 바인딩하고, 그 해당 행렬들을 또 넘겨줘야 하낟.

Light가 많아진다면 위와같은 것은 성능에 큰 무리가 있기 때문에, Light Baking을 통해서 그러한 것들을 정리해줘야할 것이고,  Shadow Map을 구현할 때, Frustum Culling을 통해서, 필요없는 모델들은 그리지 않도록 하여 좀 더 성능 최적화가 가능하다.

3. 구현 시 에러 사항들 (해결 된 것 + 해결 안 된 것)
1) Shadow Frustum Rendering하기
나는 내 엔진에서 좀 더 그래픽을 조절할 수 있게, 해당 Directional light가 어디까지 Shadow Map을 그리는지를 알고 싶었다. 그렇기 때문에, 해당 Light의 View Frustum을 그려야만 했다. 처음에 이것을 하기 위해서 Unreal Engine의 코드를 참고했는데, 프로세스는 대충 이해했으나, 내부 구현, 특히 행렬 Transformation이 어떻게 되고있는지를 확인하지 못했다.

Unreal Engine에서는 (-1 ~ 1) 범위의 정육면체 정점에 대해 어떤 한 행렬을 곱해 변환을 해주어서 그냥 프러스텀을 렌더링했는데, 그 해당 행렬이 어떻게 되어있는지를 모르겠다.

그리고 실제 Unreal Engine 에디터에서 Shadow Frustum을 렌더링하도록 했는데, SpotLight는 잘그리는데, Point Light는 항상 (0, 1, 0)만 향하게 렌더링되었다. 그리고 Directional Light는 어떤 Shadow Volume이라는 기법을 이용해서 특정 Volume 안에 있는 모든 것에 Shadow Mapping을 하는 것 같았다.

여튼, 그냥


void CGProj::CGShadowFrustumVisualizer::render(
 const glm::mat4& view, const glm::mat4& proj,
 const glm::vec3 & position, const glm::vec3 & direction, 
 float l, float r, float b, float t, float nearP, float farP)
{
 glm::vec3 vertices[8];

 // Near Left Bottom
 vertices[0].x = l;
 vertices[0].y = b;
 vertices[0].z = -nearP;

 // Near Right Bottom
 vertices[1].x = r;
 vertices[1].y = b;
 vertices[1].z = -nearP;

 // Near Left Top
 vertices[2].x = l;
 vertices[2].y = t;
 vertices[2].z = -nearP;

 // Near Right Top
 vertices[3].x = r;
 vertices[3].y = t;
 vertices[3].z = -nearP;

 // Far Left Bottom
 vertices[4].x = l;
 vertices[4].y = b;
 vertices[4].z = -farP;

 // Far Right Bottom
 vertices[5].x = r;
 vertices[5].y = b;
 vertices[5].z = -farP;

 // Far Left Top
 vertices[6].x = l;
 vertices[6].y = t;
 vertices[6].z = -farP;

 // Far Right Top
 vertices[7].x = r;
 vertices[7].y = t;
 vertices[7].z = -farP;

 glm::mat4 model(1.0);

 // Rotation and Translation
 glm::vec3 zaxis(glm::normalize(-direction));
 glm::vec3 xaxis(safeNormalize(glm::cross(glm::vec3(0, 1, 0), zaxis)));
 glm::vec3 yaxis(glm::cross(zaxis, xaxis));
 model[0][0] = xaxis.x, model[0][1] = xaxis.y, model[0][2] = xaxis.z;
 model[1][0] = yaxis.x, model[1][1] = yaxis.y, model[1][2] = yaxis.z;
 model[2][0] = zaxis.x, model[2][1] = zaxis.y, model[2][2] = zaxis.z;
 model[3][0] = position.x, model[3][1] = position.y, model[3][2] = position.z;

 // Transform
 for (int i = 0; i < 8; ++i) vertices[i] = model * glm::vec4(vertices[i], 1.0);

 // Draw Line
 insertLine(vertices[0], vertices[1], m_FrustumColor);
 insertLine(vertices[2], vertices[3], m_FrustumColor);
 insertLine(vertices[4], vertices[5], m_FrustumColor);
 insertLine(vertices[6], vertices[7], m_FrustumColor);

 insertLine(vertices[0], vertices[4], m_FrustumColor);
 insertLine(vertices[1], vertices[5], m_FrustumColor);
 insertLine(vertices[2], vertices[6], m_FrustumColor);
 insertLine(vertices[3], vertices[7], m_FrustumColor);

 insertLine(vertices[0], vertices[2], m_FrustumColor);
 insertLine(vertices[1], vertices[3], m_FrustumColor);
 insertLine(vertices[4], vertices[6], m_FrustumColor);
 insertLine(vertices[5], vertices[7], m_FrustumColor);

 renderLine(view, proj);
}

이런 식으로 직접 Orthographic Frustum 정점을 구현한 후, Rotation과 Position 행렬을 형성해준 후에, 해당 정점들을 변환하여 필요한 Line들을 그렸다.

Perspective Frustum에 대해서는
void CGProj::CGShadowFrustumVisualizer::render(
 const glm::mat4& view, const glm::mat4& proj,
 const glm::vec3 & position, const glm::vec3 & direction,
 float fov, float aspect, float nearP, float farP
 )
{
 float tanHalfFov = std::tanf(0.5 * fov);

 glm::vec3 vertices[8];

 // Near Plane
 float nearHeight = tanHalfFov * nearP * 2;
 float nearWidth = nearHeight * aspect;

 // Near Left Bottom
 vertices[0].x = nearWidth * -0.5f;
 vertices[0].y = nearHeight * -0.5f;
  vertices[0].z = -nearP;

 // Near Right Bottom
 vertices[1].x = nearWidth * 0.5f;
 vertices[1].y = nearHeight * -0.5f;
 vertices[1].z = -nearP;

 // Near Left Top
 vertices[2].x = nearWidth * -0.5f;
 vertices[2].y = nearHeight * 0.5f;
 vertices[2].z = -nearP;

 // Near Right Top
 vertices[3].x = nearWidth * 0.5f;
 vertices[3].y = nearHeight * 0.5f;
 vertices[3].z = -nearP;

 // Far Plane
 float farHeight = tanHalfFov * farP * 2;
 float farWidth = farHeight * aspect;

 // Far Left Bottom
 vertices[4].x = farWidth * -0.5f;
 vertices[4].y = farHeight * -0.5f;
 vertices[4].z = -farP;

 // Far Right Bottom
 vertices[5].x = farWidth * 0.5f;
 vertices[5].y = farHeight * -0.5f;
 vertices[5].z = -farP;

 // Far Left Top
 vertices[6].x = farWidth * -0.5f;
 vertices[6].y = farHeight * 0.5f;
 vertices[6].z = -farP;

 // Far Right Top
 vertices[7].x = farWidth * 0.5f;
 vertices[7].y = farHeight * 0.5f;
 vertices[7].z = -farP;

 glm::mat4 model(1.0);

 // Rotation and Translation
 glm::vec3 zaxis(glm::normalize(-direction));
 glm::vec3 xaxis(safeNormalize(glm::cross(glm::vec3(0, 1, 0), zaxis)));
 glm::vec3 yaxis(glm::cross(zaxis, xaxis));
 model[0][0] = xaxis.x, model[0][1] = xaxis.y, model[0][2] = xaxis.z;
 model[1][0] = yaxis.x, model[1][1] = yaxis.y, model[1][2] = yaxis.z;
 model[2][0] = zaxis.x, model[2][1] = zaxis.y, model[2][2] = zaxis.z;
 model[3][0] = position.x, model[3][1] = position.y, model[3][2] = position.z;

 // Transform
 for (int i = 0; i < 8; ++i) vertices[i] = model * glm::vec4(vertices[i], 1.0);

 // Draw Line
 insertLine(vertices[0], vertices[1], m_FrustumColor);
 insertLine(vertices[2], vertices[3], m_FrustumColor);
 insertLine(vertices[4], vertices[5], m_FrustumColor);
 insertLine(vertices[6], vertices[7], m_FrustumColor);

 insertLine(vertices[0], vertices[4], m_FrustumColor);
 insertLine(vertices[1], vertices[5], m_FrustumColor);
 insertLine(vertices[2], vertices[6], m_FrustumColor);
 insertLine(vertices[3], vertices[7], m_FrustumColor);

 insertLine(vertices[0], vertices[2], m_FrustumColor);
 insertLine(vertices[1], vertices[3], m_FrustumColor);
 insertLine(vertices[4], vertices[6], m_FrustumColor);
 insertLine(vertices[5], vertices[7], m_FrustumColor);

 renderLine(view, proj);
}
{
이 코드는 개선사항이 있다. 0.5를 일일이 곱해주지 않아도 되지만,
Frustum에 대한 수식을 이해하기 위해 일부러 다 해주었다.
}


위와 같이 정점을 구성하여 그려주었다.

이렇게 하여서 내가 원하는 Frustum의 범위를 그려줄 수 있었다.

2) Light Direction으로 인한 Light View Matrix의 Error Value
Tutorial들을 따라하다보니,
Light Space Matrix는 다음과 같이 구성된다

LightSpaceMatrix = LightProjection * LightView;

이 때 LightView는 나는 다음과 같이 구현되는데

LightView = glm::LookAt(LightPosition, LightPosition + LightDirection, WorldUp);

이 함수는 not a number(nan) 값을 만들어 낼 수 가있다.

그 예는 LightPosition(eye)이 0,0,0, LightDirection(center)이 0,1,0 이라면

vec<3, T, Q> const f(normalize(center - eye));
vec<3, T, Q> const s(normalize(cross(f, up)));
vec<3, T, Q> const u(cross(s, f));

위의 LookAt 함수의 내부구현에 의해서,  f는 (0, -1, 0)이 되고
cross(f, (0, 1,0)은 -yAxis와 yAxis의 외적이므로, 이것은 쓰레기 값을 만들어버린다.
cross할 때 0, 0, 0의 값이 나오게 되고, normalize할 때, length로 나눌 때 내적을 이용하는데, 이 때 내적 값이 0이되므로, zero divide에 의해서 nan값이 나오게 되는 것이다.

따라서 이 문제를 해결하기 위해서 safeLookAt 함수를 만들었고, 이전에 BulletPhysics Library에서 따라 만든 safeNormalize 함수를 활용하였다.


static glm::vec3 safeNormalize(const glm::vec3& vec)
{
 GPED::real l2 = glm::dot(vec, vec);
 if (l2 >= real_epsilon * real_epsilon)
 {
  return vec * (GPED::real(1) / real_sqrt(l2));
 }

 return glm::vec3(1, 0, 0);
}

static glm::mat4 safeLookAt(const glm::vec3& pos, const glm::vec3& eye, const glm::vec3& up)
{
 glm::mat4 result(1.0);

 glm::vec3 zaxis(glm::normalize(pos - eye));
 glm::vec3 xaxis(safeNormalize(glm::cross(glm::vec3(0, 1, 0), zaxis)));
 glm::vec3 yaxis(glm::cross(zaxis, xaxis));
 result[0][0] = xaxis.x;
 result[1][0] = xaxis.y;
 result[2][0] = xaxis.z;
 result[0][1] = yaxis.x;
 result[1][1] = yaxis.y;
 result[2][1] = yaxis.z;
 result[0][2] = zaxis.x;
 result[1][2] = zaxis.y;
 result[2][2] = zaxis.z;
 result[3][0] = -glm::dot(xaxis, pos);
 result[3][1] = -glm::dot(yaxis, pos);
 result[3][2] = -glm::dot(zaxis, pos);

 return result;
}

이것을 통해서, nan값을 받을 수 있는 것을 방지하여서 어떤 direction에 대해서도 light space를 잘 나타내게 하였다.

3) Shadow Acne를 해결하기 위해 Bias를 적용한 후, Peter-Panning을 해결하기 위한 CullFace (문제 해결 안됌)
이 부분은 Shadow Optimization과 관련이 있다. Tutorial을 따라서, Shadow를 구현했다면, Depth Precision에 의해서, 줄무늬가 그어지는 Shadow Acne가 생긴다. 이것을 해결하기 위해 Bias값을 적용하게 된다. 하지만 Bias를 더 큰 값으로 적용하게 된다면, 그림자가 있어야 할 곳이 없어지게 되는 Peter-Panning 현상이 생기게 된다.

이러한 문제를 해결하기 위해 튜토링러에는 glCullFace(Front_Face)로 하게 하여, 그 문제를 해결하라고 했다. 나도 이것을 시도하였으나 여기에서 문제가 생겼다.


Cube에서 그림자가 생겨야 할 부분에 저렇게 떨어지기 시작한 것이다. 그러나 glCullFace를 적용하지 않고 그냥 한다면,


이렇게 그림자가 잘 렌더링 된다. 나는 이와 같은 현상이 일어나는 이유를 다음과 같이 추측한다.

Bias에 의해서, 바닥면의 Depth가 좀 더 가까워지는데, glCullFace(Front_Face)를 하게 된다면, 그 Light 시점에서 바라보았을 때의 가장 가까운 ClosestDepth가 Depth Precision에 의해서 들쭉날쭉하게 되는데, 이러한 값의 조정 때문에, 처음에 보인 사진처럼 그림자가 끊긴 부분이 생겨나는 것이다. 나는 왜 이전에는 이러한 문제를 못봤는지 모르겠다.

https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping

여기에서 해당 댓글들을 다 읽어보았는데, 나와 같은 문제점들을 겪은 사람들이 있었다.
그래서 다른 사람들의 이것에 대한 해결책은 대부분 glCullFace를 하지 않는 것이다.
이 튜토리얼의 작성자가 한 말도 내가 위에서 추측한 것과 같다.

Shadow는 해당 Scene에서 실험을하면서 Optimization을 그 Scene에 맞게 해주어야 한다.
이번에 만들면서 어려움을 많이 겪었지만, 이게 다 나의 경험치가 되었을 거라 생각한다.










댓글 2개: