눈팅하는 게임개발자 블로그

OpenGL Advanced Lighting 6-3-1 Shadow Mapping 본문

공부한거/OpenGL

OpenGL Advanced Lighting 6-3-1 Shadow Mapping

Palamore 2020. 12. 13. 16:16

원문 사이트

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

 

LearnOpenGL - Shadow Mapping

Shadow Mapping Advanced-Lighting/Shadows/Shadow-Mapping Shadows are a result of the absence of light due to occlusion. When a light source's light rays do not hit an object because it gets occluded by some other object, the object is in shadow. Shadows add

learnopengl.com

번역 사이트

gyutts.tistory.com/169?category=755809

 

Learn OpenGL - Advanced Lighting : Shadow Mapping (1)

link : https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping Shadow Mapping  그림자는 폐색으로 인한 빛의 부재로 인한 결과이다. 광원의 광선이 어떤 물체에 의해 가려지기 때문에 물체에 부딪..

gyutts.tistory.com

Shadow Mapping

그림자는 폐색으로 인한 빛의 부재로 인한 결과이다.

광원의 광선이 어떤 물체에 의해 가려지기 때문에 가려진 그 물체는 그림자 속에 있다.

그림자는 밝은 장면에 많은 사실감을 더하고, 관찰자가 물체 간의 공간저 관계를 더 쉽게 인식할 수 있게 한다.

그들은 scene과 사물에 깊이 감을 준다.

예를 들어 그림자가 있고 없는 장면의 다음 이미지를 살펴보자.

그림자를 사용하면 오브젝트가 서로 어떻게 관련되는지 훨씬 더 분명해진다는 것을 알 수 있다.

예를 들어, 큐브 중 하나가 다른 큐브 위로 떠 다니는 사실은 그림자가 있을 때 훨씬 더 두드러진다.

 

현재의 실시간 연구에서 완벽한 그림자 알고리즘이 아직 개발되지 않았기 때문에, 그림자를 구현하는 것은

다소 까다롭다. 몇가지 좋은 음영 근사법이 있지만,

그것들은 모두 구현하기 위해 고려해야 하는 약간의 단점과 성가신 부분이 있다.

 

대부분의 비디오 게임에서 사용되는 한 가지 기술은 적절한 결과를 제공하고 비교적 쉽게 구현할 수 있는 쉐도우 매핑이다.

그림자 매핑은 너무 이해하기 어렵지 않고 성능도 많이 들지 않으며,

전 방향성 음영 맵과 계단식 음영 맵과 같은 고급 알고리즘으로 쉽게 확장된다.

 

Shadow Mapping

그림자 매핑 뒤에 있는 개념은 매우 간단하다.

빛의 관점에서 장면을 렌더링하고 빛의 관점에서 볼 때 모든 것이 켜져 있고 볼수 없는 모든 것이 그림자에 있어야 한다.

한 상자와 광원 사이에 큰 상자가 있는 바닥 섹션을 상상해보자.

이는 다음의 이미지와 같다.

여기서 파란색 선은 광원이 볼 수 있는 조각을 나타낸다.

그림자가 있는 부분으로 렌더링된다. 광원으로부터 선 또는 광선을 가장 오른쪽 상자의 단편에 그릴 경우

가장 오른쪽에 있는 컨테이너에 닿기 전에 먼저 광선이 떠다니는 컨테이너에 닿는 것을 볼 수 있다.

결과적으로, 부동 컨테이너의 fragment가 그려지고 맨 오른쪽 컨테이너의 fragment가 그려지지 않으므로 그림자가 생긴다.

 

광선이 처음으로 물체에 닿는 지점을 얻고 이 가장 가까운 지점을 이 광선의 다른 지점과 비교하려고 한다.

그 다음 기본 테스트를 수행하여 테스트 포인트의 광선 위치가 가장 가까운 포인트보다 광선 아래에 있는지 확인한다.

그렇다면 테스트 포인트에 그림자가 드리워야 한다.

그러한 광원으로부터 수천 개의 광선을 반복해서 반복하는 것은 극히 비효율적인 접근이며,

이는 실시간 렌더링에 적합하지 않다.

대신 익숙한 것을 사용할 것이다.(depth buffer를 사용한다.)

 

depth testing 강좌에서 깊이 버퍼의 값은 카메라의 관점에서 [0, 1]로 고정된 fragment의 깊이에 해당한다는 것을 기억할 것이다.

빛의 관점에서 장면을 렌더링하고 결과 깊이 값을 텍스처에 저장한다면 어떨까?

이렇게 하면 광원의 관점에서 볼 때 가장 가까운 깊이 값을 샘플링할 수 있다.

결국 깊이 값은 광원의 관점에서 볼 수 있는 첫 번째 fragment를 보여준다.

이 모든 깊이 값을 깊이 맵 또는 그림자 맵이라고 하는 텍스처에 저장한다.

왼쪽 이미지는 큐브 아래의 표면에 그림자를 드리우는 방향 광원을 보여준다.

깊이 맵에 저장된 깊이 값을 사용해 가장 가까운 점을 찾고 이를 사용해 fragment에 그림자가 드리워지는지 여부를 확인한다.

광원에 특정한 뷰 및 투영 행렬을 사용해 장면을 렌더링함으로써 깊이 맵을 생성한다.

이 투영 및 뷰 매트릭스는 3D 위치를 조명의 가시적인 좌표 공간으로 변환하는 변환 T를 함께 형성한다.

Directional light는 무한히 멀리 모델링된 위치를 갖지 않는다. 그러나 그림자 매핑을 위해 빛의 관점에서 장면을 렌더링해야 하므로 빛 방향의 어딘가의 위치에서 장면을 렌더링해야 한다.

오른쪽 이미지에서 동일한 Directional light와 viewer를 볼 수 있다.

그림자가 있는지 여부를 결정해야하는 지점 P에서 fragment를 렌더링한다.

이렇게 하기 위해 먼저 T를 사용해 점 P를 빛의 좌표 공간으로 변환한다.

점 P는 빛의 관점에서 본 것처럼 이제 Z 좌표는 이 예시의 depth 값에 해당한다.(이 예시에서는 0.9)

점 P를 사용해 깊이 맵을 인덱싱하여 광원의 관점에서 가장 가까운 눈에 보이는 깊이를 얻을 수 있다.

이 깊이는 샘플 깊이 0.4의 지점 C에 존재한다.

깊이 맵을 인덱싱하면 점 P에서의 깊이보다 더 작은 깊이가 반환되었으므로 점 P가 폐색되어 그림자에 있다고 결론을

내릴 수 있다.

 

그림자 매핑은 두 가지 패스로 구성된다. 먼저 깊이 맵을 렌더링하고 두 번째 패스에서 장면을 보통으로 렌더링하고

생성된 깊이 맵을 사용해 조각이 그림자에 있는 지 여부를 계산한다.

다소 복잡하다고 생각할 수 있겠지만 기술을 단계별로 실행해보면 이 방법이 적절하다고 생각될 것이다.

 

The depth map

첫 번째 단계에서는 깊이 맵을 생성해야 한다. 깊이 맵은 그림자를 계산할 때 사용하는 광원의 관점에서 렌더링된 깊이 텍스처이다.

장면의 렌더링된 결과를 텍스처에 저장해야 하기 때문에 프레임 버퍼가 필요한 부분이다.

 

먼저 깊이 맵을 렌더링하기 위한 프레임버퍼 객체를 만든다.

unsigned int depthMapFBO;
glGenFramebuffers(1, &depthMapFBO);  

다음으로 프레임 버퍼의 깊이 버퍼로 사용할 2D 텍스처를 만든다.

const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;

unsigned int depthMap;
glGenTextures(1, &depthMap);
glBindTexture(GL_TEXTURE_2D, depthMap);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, 
             SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);
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);  

깊이 맵을 생성하는 것은 너무 복잡해 보이지 않아야 한다.

여기선 깊이 값만을 다루기 때문에 텍스처의 형식을 GL_DEPTH_COMPONENT로 지정한다.

텍스처의 너비와 높이를 1024로 지정한다. 이는 깊이 맵의 해상도이다.

 

생성된 깊이 텍스처를 사용해 프레임 버퍼의 깊이 버퍼로 연결할 수 있다.

glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depthMap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);  

광원의 관점에서 장면을 렌더링할 때 깊이 정보가 필요하기 때문에 색상 버퍼가 필요하지 않다.

그러나 framebuffer 객체는 색상 버퍼가 없으면 완전하지 않으므로 OpenGL에 명시적으로 말해 색상 데이터를 렌더링하지 않아도 된다.

glDrawBuffer와 glReadBuffer를 사용해 읽기와 그리기 버퍼를 모두 GL_NONE으로 설정하면 된다.

 

텍스처에 깊이 값을 렌더링하는 제대로 구성된 프레임 버퍼를 사용하면 첫 번째 패스(depth map 생성)를 시작할 수 있다.

두 패스의 전체 렌더링 단계는 다음과 같다.

// 1. first render to depth map
glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
    glClear(GL_DEPTH_BUFFER_BIT);
    ConfigureShaderAndMatrices();
    RenderScene();
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// 2. then render scene as normal with shadow mapping (using depth map)
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_2D, depthMap);
RenderScene();

이 코드는 세부 사항을 생략했지만 쉐도우 매핑에 대한 일반적인 개념을 제시한다.

여기서 중요한 점은 glViewport에 대한 호출이다.

그림자 맵은 원래 장면을 렌더링하는 것과 다른 해상도를 가지고 있기 때문에 그림자 맵의 크기를 수용하기 위해

뷰포트 매개 변수를 변경해야 한다.

뷰포트 매개 변수를 업데이트하는 것을 잊어버리면 결과 깊이 맵이 불완전하거나 작아진다.

 

Light space transform

위의 간략한 코드에서 알 수 없는 것은 ConfigureShaderAndMatrices 함수이다.

두 번째 단계에서는 평소에 하던 것과 비슷한 작업을 한다.

적절한 투영 및 뷰 행렬이 설정되고 객체당 관련 모델 행렬이 있는지 확인한다.

그러나 첫 번째 패스에서 빛의 관점에서의 장면을 렌더링하기 위해 다른 투영 및 뷰 매트릭스를 사용했었다.

 

방향성 광원을 모델링하기 때문에 모든 광선이 평행하다.

이러한 이유 때문에 원근감 변형이 없는 광원에 직교 투영행렬을 사용할 것이다.

float near_plane = 1.0f, far_plane = 7.5f;
glm::mat4 lightProjection = glm::ortho(-10.0f, 10.0f, -10.0f, 10.0f, near_plane, far_plane);

다음은 이 강좌의 데모 씬에서 사용된 직교 투영 행렬의 예이다.

투영 행렬은 가시적인 것의 범위를 간접적으로 결정하기 때문에

클리핑되지 않은 것은 투영 절도체의 크기가 깊이 맵에 있어야 할 객체를 정확하게 포함하는지 확인하기를 원할 것이다.

오브젝트나 fragment가 깊이 맵에 없으면 그림자가 생기지 않는다.

 

빛의 관점에서 볼 수 있도록 각 객체를 변형하기 위해 view 매트릭스를 생성하려면 악명 높은 glm::lookAt 함수를 사용하라.

이번에는 광원의 위치가 장면의 중심을 바라본다.

glm::mat4 lightView = glm::lookAt(glm::vec3(-2.0f, 4.0f, -1.0f), 
                                  glm::vec3( 0.0f, 0.0f,  0.0f), 
                                  glm::vec3( 0.0f, 1.0f,  0.0f));  

이 두가지를 결합하면 광원에서 볼 수 있는 공간으로 각각의 world 공간 벡터를 변환하는 밝은 공간 변환 행렬을 얻을 수 있다.

 

이는 정확하게 깊이 맵을 렌더링하기 위해 필요한 것이다.

glm::mat4 lightSpaceMatrix = lightProjection * lightView; 

이 lightSpcaeMatrix는 이전에 T로 표시했던 변환 행렬이다.

이 lightSpaceMatrix를 사용하면 쉐이더에 투영 및 뷰 행렬의 등가물을 제공하는 한 장면을 평소와 같이 렌더링할 수 있다.

그러나 깊이 값만 신경쓰고 메인 쉐이더에서는 값 비싼 fragment 계산이 전부가 아니다.

퍼포먼스를 저장하기 위해서는 깊이 맵에 렌더링하기 위해 다른, 그러나 훨씬 더 간단한 쉐이더를 사용할 것이다.

 

Render to depth map

빛의 관점에서 장면을 렌더링할 때 정점을 밝은 공간으로 변환하는 단순한 쉐이더를 사용하는 것이 훨씬 더 많다.

 

simpleDepthShader와 같은 단순한 쉐이더의 경우 다음 정점 쉐이더를 사용한다.

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

이 정점 쉐이더는 lightSpaceMatrix를 사용해 오브젝트별 모델, 정점을 취하고 모든 정점을 밝은 공간으로 변환한다.

컬러 버퍼가 없으므로 결과 fragment는 처리가 필요하지 않으므로 빈 fragment shader를 사용해도 된다.

#version 330 core

void main()
{             
    // gl_FragDepth = gl_FragCoord.z;
}  

빈 fragment shader는 아무런 처리도 하지 않고 실행이 끝나면 깊이 버퍼가 업데이트된다.

명시적으로 한 줄의 주석처리를 제거해 깊이를 설정할 수 있다.

그러나 이는 실제로 장면 뒤에서 발생하게 된다.

 

깊이 버퍼 렌더링은 이제 효과적으로 수행된다.

simpleDepthShader.use();
glUniformMatrix4fv(lightSpaceMatrixLocation, 1, GL_FALSE, glm::value_ptr(lightSpaceMatrix));

glViewport(0, 0, SHADOW_WIDTH, SHADOW_HEIGHT);
glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
    glClear(GL_DEPTH_BUFFER_BIT);
    RenderScene(simpleDepthShader);
glBindFramebuffer(GL_FRAMEBUFFER, 0);  

여기서 RenderScene 함수는 쉐이더 프로그램을 사용하고 모든 관련 드로잉 함수를 호출하며

필요한 경우 해당 모델 행렬을 설정한다.

 

결과는 빛의 관점에서 볼 수 있는 각 fragment의 가장 가까운 깊이를 유지하는 멋지게 채워진 깊이 버퍼이다.

이 텍스처를 화면을 채우는 2D 쿼드위에 투사함으로써 다음과 같은 결과를 얻는다.

 

깊이 맵을 쿼드에 렌더링하기 위해 다음과 같은 fragment shader를 사용하였다.

#version 330 core
out vec4 FragColor;
  
in vec2 TexCoords;

uniform sampler2D depthMap;

void main()
{             
    float depthValue = texture(depthMap, TexCoords).r;
    FragColor = vec4(vec3(depthValue), 1.0);
}  

원근 투영을 사용할 때 깊이가 비선형이기 때문에 직교 투영 행렬 대신 원근 투영 행렬을 사용해 깊이를 표시할 때

약간의 변화가 있었음을 유념해야 한다.

이 강좌의 마지막 부분에서는 이러한 미묘한 차이점에 대해 설명한다.

 

Rendering Shadows

적절하게 생성된 깊이 맵을 사용해 실제 그림자를 생성할 수 있다.

fragment에 그림자가 드리워지는지를 확인하는 코드는 fragment shader에서(분명히) 실행되지만,

정점 쉐이더에서 light-space 변환을 수행한다.

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;

out VS_OUT {
    vec3 FragPos;
    vec3 Normal;
    vec2 TexCoords;
    vec4 FragPosLightSpace;
} vs_out;

uniform mat4 projection;
uniform mat4 view;
uniform mat4 model;
uniform mat4 lightSpaceMatrix;

void main()
{    
    vs_out.FragPos = vec3(model * vec4(aPos, 1.0));
    vs_out.Normal = transpose(inverse(mat3(model))) * aNormal;
    vs_out.TexCoords = aTexCoords;
    vs_out.FragPosLightSpace = lightSpaceMatrix * vec4(vs_out.FragPos, 1.0);
    gl_Position = projection * view * vec4(vs_out.FragPos, 1.0);
}

여기서 새로 추가된 것은 FragPosLightSpace라는 추가 출력 벡터이다.

동일한 lightSpaceMatrix를 가져와서 월드 공간 정점 위치를 밝은 공간으로 변환한다.

정점 쉐이더는 일반적으로 변환된 world-space 정점 위치 vs_out.FragPos와 변환된 light-space vs_out.FragPosLightSpace를

fragment shader에 전달한다.

 

scene 렌더링에 사용할 fragment shader는 Blinn-Phong 조명 모델을 사용한다.

fragment shader 내에서 fragment에 그림자가 드리웠을 때 1.0이거나 그림자가 드리우지 않았을 때 0.0인

그림자 값을 계산한다. 결과로 생성되는 확산 및 반사 색상에 이 그림자 구성 요소가 곱해진다.

빛의 산란으로 인해 그림자가 거의 완전히 어두워지지 않기 때문에 그림자 곱셈에서 ambient 색상을 남겨둔다.

#version 330 core
out vec4 FragColor;

in VS_OUT {
    vec3 FragPos;
    vec3 Normal;
    vec2 TexCoords;
    vec4 FragPosLightSpace;
} fs_in;

uniform sampler2D diffuseTexture;
uniform sampler2D shadowMap;

uniform vec3 lightPos;
uniform vec3 viewPos;

float ShadowCalculation(vec4 fragPosLightSpace)
{
    [...]
}

void main()
{           
    vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
    vec3 normal = normalize(fs_in.Normal);
    vec3 lightColor = vec3(1.0);
    // ambient
    vec3 ambient = 0.15 * color;
    // diffuse
    vec3 lightDir = normalize(lightPos - fs_in.FragPos);
    float diff = max(dot(lightDir, normal), 0.0);
    vec3 diffuse = diff * lightColor;
    // specular
    vec3 viewDir = normalize(viewPos - fs_in.FragPos);
    float spec = 0.0;
    vec3 halfwayDir = normalize(lightDir + viewDir);  
    spec = pow(max(dot(normal, halfwayDir), 0.0), 64.0);
    vec3 specular = spec * lightColor;    
    // calculate shadow
    float shadow = ShadowCalculation(fs_in.FragPosLightSpace);       
    vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;    
    
    FragColor = vec4(lighting, 1.0);
}

fragment shader는 davnaced Lighting 강좌에서 사용한 것의 사본이지만 그림자 계산이 추가되었다.

대부분의 그림자 작업을 수행하는 ShadowCalculation 함수를 선언했다.

fragment shader의 종단에서 확산 및 반사 기여에 그림자 성분의 역행렬을 곱해 fragment가 얼마만큼 그림자가

생기지 않았는지를 계산한다.

이 fragment shader는 첫 번째 렌더 패스에서 생성된 깊이 맵과 light-space 조각 위치를 추가 입력으로 사용한다.

 

fragment에 그림자가 생기는지 여부를 확인하기 위해 수행해야 할 첫 번째 작업은 클립 영역의 밝은 공간 fragment 위치를

NDC(Normalized Device Coordinates)로 변환하는 것이다.

정점 쉐이더의 gl_position에 클립 공간 정점 위치를 출력할 때, OpenGL은 자동으로 원근법 나누기를 한다.

x, y, z 구성 요소를 벡터의 w 구성 요소로 나눠서 [-w, w] 범위의 클립 공간 좌표를 [-1, 1]로 변환한다.

클립 공간 FragPosLightSpace가 gl_Position을 통해 fragment shader에 전달되지 않았기 때문에

해당 관점을 직접 분리해야 한다.

float ShadowCalculation(vec4 fragPosLightSpace)
{
    // perform perspective divide
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    [...]
}

이는 [-1, 1]의 범위에서 fragment의 light-space 위치를 반환한다.

직교 투영 행렬을 사용할 때 정점의 w 구성요소는 그대로 유지되므로 이 단계는 실제로 의미가 없다.
그러나 원근 투영을 사용할 때 이 선을 유지하면 두 투영 행렬 모두에서 사용할 수 있다.

깊이 맵의 깊이는 [0, 1]의 범위에 있고 또한 깊이 맵에서 샘플링하기 위해 projCoords를 사용해

NDC 좌표를 [0,1] 범위로 변환한다.

projCoords = projCoords * 0.5 + 0.5; 

이 투영된 좌표를 사용하면 projCoords의 결과 [0, 1] 좌표가 첫 번째 렌더 패스에서 변환된 NDC 좌표와 직접 일치하므로

깊이 맵을 샘플링할 수 있다.

 

이는 빛의 관점에서 가장 가까운 깊이를 반환한다.

float closestDepth = texture(shadowMap, projCoords.xy).r;  

fragment의 현재 깊이를 얻으려면 광원의 관점에서 fragment의 깊이와 동일한 투영된 벡터의 z좌표를 검색하면 된다.

float currentDepth = projCoords.z;  

실제 비교는 단순히 currentDepth가 nearestDepth보다 높은지 여부를 확인하고,

그렇다면 fragment가 그림자 안에 있는지 여부를 확인하는 것이다.

float shadow = currentDepth > closestDepth  ? 1.0 : 0.0;  

전체 ShadowCalculation 함수는 다음과 같다.

float ShadowCalculation(vec4 fragPosLightSpace)
{
    // perform perspective divide
    vec3 projCoords = fragPosLightSpace.xyz / fragPosLightSpace.w;
    // transform to [0,1] range
    projCoords = projCoords * 0.5 + 0.5;
    // get closest depth value from light's perspective (using [0,1] range fragPosLight as coords)
    float closestDepth = texture(shadowMap, projCoords.xy).r; 
    // get depth of current fragment from light's perspective
    float currentDepth = projCoords.z;
    // check whether current frag pos is in shadow
    float shadow = currentDepth > closestDepth  ? 1.0 : 0.0;

    return shadow;
}  

이 쉐이더를 활성화하고 적절한 텍스처를 바인딩하고 두 번째 렌더 패스에서 기본 투영 및 뷰 행렬을 활성화하면

아래 이미지와 같은 결과가 나타난다.

그림자는 제대로 보이는데 필요 이상으로 어두운 느낌이다.

 

Improving shadow maps

쉐도우 매핑의 기본 원리를 알 수 있었지만, 알 수 있듯이 더 더 나은 결과를 위해 수정하고자 하는 그림자 매핑과

관련된 몇 가지 아티팩트가 존재한다. 다음 섹션에서 살펴보자.

 

Shadow acne

이전 이미지에서 분명히 잘못된 부분이 존재한다. 이를 더 확대하여 보면 아주 명확한 Moire같은 패턴을 볼 수 있다.

교묘하게 검은선으로 렌더링된 바닥 쿼드를 볼 수 있다.

이 그림자 매핑 아티팩트는 shadow acne라고 하며 간단한 이미지로 설명할 수 있다.

그림자 맵은 해상도에 의해 제한되기 때문에 여러 fragment가 광원에서 상대적으로 멀리 떨어져 있을 때

깊이 맵에서 동일한 값을 샘플링할 수 있다.

이미지는 각 기울어진 패널이 깊이 맵의 단일 텍셀을 나타내는 바닥을 표시한다.

보다시피 여러 단편은 동일한 깊이 샘플을 샘플링한다.

 

이는 일반적으로 문제가 없겠지만 광원이 표면을 향한 각도를 볼 때 깊이 맵은 각도에서 렌더링되기 때문에 문제가 된다.

그 다음 여러 조각이 동일한 기울어진 깊이 텍셀에 액세스하는 반면 일부는 위아래이며 일부는 바닥 아래에 있다.

여기서 그림자의 불일치가 발생한다.

이 때문에 일부 fragment는 그림자 안에 있는 것으로 간주되고 일부 조각은 이미지에서 sprite 패턴을 제공하지 않는다.

 

shadow bias라고 불리는 작은 해킹으로 이 문제를 해결할 수 있다.

여기서 작은 bias amount로 표면의 깊이를 간단하게 offset하여 fragment가 표면 아래에서 잘못 간주되지 않도록 한다.

bias를 적용하면 모든 샘플의 깊이가 표면의 깊이보다 작아지므로 전체 표면이 그림자없이 올바르게 조면된다.

 

다음과 같은 편향을 구현할 수 있다.

float bias = 0.005;
float shadow = currentDepth - bias > closestDepth  ? 1.0 : 0.0;  

0.005의 그림자 기울기는 해당 scene의 문제를 상당부분 해결하지만 광원에 대한 가파른 각도를 갖는 표면은

여전히 shadow acne를 유발할 수 있다.

 

보다 견고한 접근법은 빛에 대한 표면 각을 기준으로 기울기의 양을 변경하는 것이다.

dot product로 해결할 수 있다.

float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);  

여기서 표면의 법선 방향과 빛의 방향을 기준으로 0.0의 최대 기울기와 0.005의 최소 기울기를 갖는다.

이 방식으로 광원에 거의 수직인 바닥과 같은 면은 작은 편향을 얻는 반면

큐브의 측면과 같은 면은 훨씬 큰 편향을 얻는다.

다음 이미지는 동일한 장면이지만 이제는 그림자 편향을 보여준다.

훨씬 더 나은 결과이다.

올바른 기울기 값을 선택하려면 각 scene마다 약간의 조정이 필요하지만

대부분의 경우 모든 shadow acne가 제거될 때까지 기울기를 증가시키면 되는 일이다.

 

Peter panning

그림자 기울기를 사용할 때의 단점은 객체의 실제 깊이에 offset을 적용해버린다는 점이다.

결과적으로 기울기는 아래에서 볼 수 있듯이 실제 오브젝트 위치와 비교해 보이는 그림자 오프셋을 볼 만큼

충분히 커질 수 있다.

이 그림자 아티팩트는 피터 패닝(peter panning)이라고 한다.

왜냐면 사물이 그림자에서 약간 떨어져 나온 것처럼 보이기 때문이다.

깊이 맵을 렌더링할 때 앞면 컬링을 사용해 대부분의 피터 패닝 문제를 해결할 수 있다.

OpenGL이 기본적으로 back-face방향으로 면을 컬링한다는 것을 기억할 것이다.

OpenGL에게 그림자 매핑이 stage될 때 front-face를 컬링하라고 지시해야 한다.

순서를 바꿈으로써 이를 해결한다.

 

깊이 맵의 깊이 값만 필요하기 때문에 solid 오브젝트의 경우 앞면 또는 뒷면의 깊이를 취하는 것이 중요하지 않다.

그들의 면 깊이를 사용할 때 객체 내부에 그림자가 있으면 문제가 되지 않으므로 잘못된 결과를 주지는 않는다.

대부분 피터 패닝을 고치기 위해 앞면을 cull한다. 먼저 GL_CULL_FACE를 활성화해야 한다.

glCullFace(GL_FRONT);
RenderSceneToDepthMap();
glCullFace(GL_BACK); // don't forget to reset original culling face

이렇게 하면 피터 패닝 문제가 효과적으로 해결되지만 뚫려있지 않은 내부가 실제로 있는 단단한 물체에만 적용된다.

예를 들어 scene에서 이것은 큐브에서 완벽하게 작동하지만 front-face를 culling하면 방정식에서 바닥을 완전히 제거하므로

바닥에서는 작동하지 않는다.

바닥은 하나의 평면이므로 완전히 배제될 것이다.

이 트릭으로 피너 패닝을 해결하려면 조심스럽게 개체의 front-face를 제거해야 한다.

바닥을 통쨰로 날려버렸다.

또다른 고려사항은 멀리 떨어진 큐브와 같이 그림자 수신기에 가까운 물체가 여전히 잘못된 결과를 나타낼 수 있다는 것이다.

그것이 의미가 있는 사물에 front-face 컬링을 사용하는데 주의를 기울여야 한다.

그러나 일반적인 기울기 값을 사용하면 일반적으로 피터 패닝을 피할 수 있다.

 

Over sampling

또다른 시각적 불일치는, 빛의 시점에서 볼 수 있는 절도체(frustum) 밖의 일부 지역이 그늘에 가려져 있지 않을 때

그늘에 가려져 있는 것처럼 보이는 것이다.

이는 광원의 절두체 외부에 투영된 좌표가 1.0보다 높기 때문에 [0, 1]의 기본값 범위를 벗어나는 깊이 텍스처를

샘플링하기 때문에 발생한다.

텍스처의 배치 방법에 따라 광원의 실제 깊이 값을 기반으로 하지 않고 잘못된 깊이 결과를 얻는다.

이미지에서 상상의 빛의 영역이 있고, 이 영역 외부의 큰 부분이 그림자에 있음을 알 수 있다.

이 영역은 바닥에 투영된 깊이 맵의 크기를 나타낸다.

그 이유는 이전에 깊이 맵의 래핑 옵션을 GL_REPEAT으로 설정했기 때문이다.

 

차라리 깊이 맵의 범위 밖의 모든 좌표가 1.0의 깊이를 가지도록 하여 이 좌표가 결코 그림자에 있지 않도록 하는 것이 낫다.

경계 색을 저장하고 깊이 맵의 텍스처 랩 옵션을 GL_CLAMP_TO_BORDER로 설정하면 된다.

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

이제 깊이 맵의 [0,1]좌표 범위 밖에서 샘플링할 때마다 텍스처 함수는 항상 1.0의 깊이를 반환해 0.0의 그림자 값을 반환한다.

결과는 이제 훨씬 더 그럴듯해 보인다.

무슨 말인지는 알겠는데 내 경우 결과물이 크게 다르지는 않다.

아직 어두운 영역을 보여주는 부분이 있는 것 같다.

그것들은 빛의 직각 절두체의 멀리 떨어진 평면 밖의 좌표이다.

이 어두운 영역은 항상 그림자 방향을 보고 광원의 절두체의 맨 끝에서 발생한다.

 

투영된 좌표는 z 좌표가 1.0보다 클 때 빛의 먼 평면보다 더 크다.

이 경우 GL_CLAMP_TO_BORDER 랩핑 메소드는 좌표의 z구성 요소와 깊이 맵 값을 비교할 때 더 이상 작동하지 않는다.

1.0보다 큰 z에 대해서는 항상 true를 반환한다.

 

투영된 벡터의 z좌표가 1.0을 초과할 때마다 그림자 값을 0.0으로 강제 설정하기 때문에 이 문제를 비교적 쉽게 해결할 수 있다.

float ShadowCalculation(vec4 fragPosLightSpace)
{
    [...]
    if(projCoords.z > 1.0)
        shadow = 0.0;
    
    return shadow;
}  

먼 평면을 확인하고 깊이 맵을 수동으로 지정된 테두리 색상으로 고정하면 깊이 맵의 오버 샘플링이 해결되고

마지막으로 원하는 결과가 나온다.

드디어 좀 뭔가 달라보인다.

이 모든 결과는 투영된 부분 좌표가 깊이 맵 범위 내에 있는 그림자만 가질 수 있음을 의미한다.

따라서 이 범위를 벗어나는 부분은 그림자가 보이지 않는다.

게임은 일반적으로 이것이 멀리서만 발생하기 때문에 이전의 명백한 흑색 지역보다 훨씬 더 그럴듯한 결과가 된다.

 

PCF

그림자는 풍경에 대한 멋진 추가 사항이지만 여전히 완벽한 것은 아니다.

그림자를 확대하려는 경우 그림자 맵핑의 해상도 종속성이 드러나기 때문이다.

깊이 맵은 고정된 해상도를 가지고 있기 때문에 깊이는 텍셀당 하나 이상의 fragment에 자주 분포한다.

결과적으로 여러 fragment가 깊이 맵에서 동일한 깊이 값을 샘플링하고 동일한 그림자 결과를 얻는다.

이렇게 하면 이런 들쭉날쭉한 가장자리가 생긴다.

 

깊이 맵 해상도를 높이거나 빛의 절두체를 장면에 최대한 가깝게 설정하여 이러한 짙은 그림자를 줄일 수 있다.

 

이런 들쭉날쭉한 가장자리에 대한 또 다른 해결책은 PCF 또는 percentage-closer-filtering이라 불리는데,

이는 더 부드러운 그림자를 생성하는 많은 다른 필터링 기능을 호스트해 덜 거칠거나 단단하게 보이게 한다.

아이디어는 약간 다른 텍스처 좌표로 매번 깊이 맵에서 한 번 이상 샘플링하는 것이다.

각각의 개별 샘플에 대해 그것이 그림자인지 여부를 확인한다.

모든 하위 결과가 결합되어 평균화되고 멋진 부드러운 음영을 얻을 수 있다.

 

PCF의 간단한 구현은 깊이 맵의 주변 텍셀을 샘플링하고 결과를 평균화하는 것이다.

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;

여기서 textureSize는 밉맵 레벨 0에서 주어진 샘플러 텍스처의 너비와 높이의 vec2를 반환한다.

1이상으로 나누면 텍스처 좌표를 오프셋하는데 사용하는 단일 텍셀의 크기를 반환해 각각의 새 샘플이

다른 깊이 값을 샘플링하는지 확인한다.

여기서 투영된 좌표의 x와 y값 주위의 9개 값을 샘플링하고, 그림자 폐색을 테스트한 다음 최종 결과를 총 샘플 수로 평균화한다.

 

더 많은 샘플을 사용하거나 texelSize 변수를 변경하면 부드러운 그림자의 품질을 높일 수 있다.

아래에 간단한 PCF를 적용해 그림자를 볼 수 있다.

텍스처 괜히 어두운걸로 했다. 잘 안보인다.

멀리서 보면 그림자는 훨씬 훌륭해보인다.

확대하면 여전히 그림자 맵핑의 해상도 아티팩트를 볼 수 있지만 일반적으로 대부분의 응용 프로그램에서 좋은 결과를

얻을 수 있다.

 

실제로 PCF와 부드러운 그림자의 품질을 상당히 향상시키는 몇 가지 기술이 있지만 이 강좌의 길이를 위해 나중에 논의할 것이다.

 

Orthgraphic vs projection

정사영 또는 투영 행렬을 사용해 깊이 맵을 렌더링하는 경우에는 차이점이 있다.

직교 투영 행렬은 원근감을 가지고 scene을 변형시키지 않으므로 모든 뷰/광선이 평행해서 방향 조명을 위한 훌륭한

투영 행렬이 된다.

그러나 perspective projection 행렬은 다른 결과를 제공하는 원근감에 따라 모든 꼭지점을 변형한다.

다음 이미지는 두가지 투영 방법의 서로 다른 shadow 영역을 보여준다.

원근 투영은 방향 조명과 달리 실제 위치가 있는 광원에 더 적합하다.

원근 투영은 스포트라이트 및 점 광원에 가장 많이 사용되는 반면 직교 투영은 방향 조명에 사용된다.

 

투시 투영 행렬을 사용하는 또 다른 미묘한 차이점은 깊이 버퍼를 시각화하면 종종 거의 완전한 흰색 결과를 얻게 된다는 것이다.

이는 투시 투영에서 깊이가 가까운 평면에 가까운 눈에 띄는 범위의 대부분을 갖는 비선형 깊이 값으로 변환되기 때문에 발생한다.

직교 투영에서 했던 것처럼 깊이 값을 제대로 보려면 depth testing 강좌에서 설명한 대로

비선형 깊이 값을 선형으로 변환해야 한다.

#version 330 core
out vec4 FragColor;
  
in vec2 TexCoords;

uniform sampler2D depthMap;
uniform float near_plane;
uniform float far_plane;

float LinearizeDepth(float depth)
{
    float z = depth * 2.0 - 1.0; // Back to NDC 
    return (2.0 * near_plane * far_plane) / (far_plane + near_plane - z * (far_plane - near_plane));
}

void main()
{             
    float depthValue = texture(depthMap, TexCoords).r;
    FragColor = vec4(vec3(LinearizeDepth(depthValue) / far_plane), 1.0); // perspective
    // FragColor = vec4(vec3(depthValue), 1.0); // orthographic
}  

이는 직교 투영에서 본 것과 비슷한 깊이 값을 보여준다.

이는 디버깅에만 유용하다. 상대적 깊이가 변하지 않으므로 depth testing은 정사영 또는 투영 행렬과 동일하게 유지된다.