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

OpenGL Advanced Lighting 6-3-2 Point Shadows 본문

공부한거/OpenGL

OpenGL Advanced Lighting 6-3-2 Point Shadows

Palamore 2020. 12. 13. 17:27

원문 사이트

learnopengl.com/Advanced-Lighting/Shadows/Point-Shadows

 

LearnOpenGL - Point Shadows

Point Shadows Advanced-Lighting/Shadows/Point-Shadows In the last chapter we learned to create dynamic shadows with shadow mapping. It works great, but it's mostly suited for directional (or spot) lights as the shadows are generated only in the direction o

learnopengl.com

번역 사이트

gyutts.tistory.com/172?category=755809

 

Learn OpenGL - Advanced Lighting : Point Shadows (1)

link : https://learnopengl.com/Advanced-Lighting/Shadows/Point-Shadows Point Shadows  마지막 튜토리얼에서는 그림자 맵핑을 사용해 동적 그림자를 만드는 방법을 배웠다. 이것은 훌륭하게 작동하지만 그림..

gyutts.tistory.com

Point Shadows

직전 강좌에서 그림자 매핑을 사용하여 동적 그림자를 만드는 방법을 배웠다.

이는 훌륭하게 작동하지만 그림자가 광원의 한 방향으로만 생성되기 때문에 방향성 조명에만 적합하다.(태양과 같은)

 

이 강좌에서 다룰 것은 주변의 모든 방향에서 동적인 그림자를 생성하는 것이다.

ㅎ녀재 사용하고 있는 기술은 실제 점 광원이 모든 방향으로 그림자를 드리우기 때문에 점 광원에 완벽하다.

이 기술은 point shadows(light)또는 이전에는 omnidirectional shadow map으로 알려져 있다.

이 강좌는 이전의 그림자 매핑 강좌를 기반으로 하므로 기존의 쉐도우 매핑에 익숙하지 않으면
쉐도우 매핑을 먼저 익히는 것이 좋다.

알고리즘은 directional shadow mapping과 거의 동일하다.

광원의 관점에서 깊이 맵을 생성하고 현재 fragment 위치를 기반으로 깊이 맵을 샘플링하고,

각 fragment를 저장된 깊이 값과 비교해 그림자가 생성되는지를 확인한다.

directional shadow mapping과 omnidirectional shadow mapping의 주요 차이점은 사용되는 깊이 맵이다.

 

사용할 깊이 맵은 점 광원의 모든 주변 방향에서 Scene을 렌더링해야 하며 정상적인 2D 깊이 맵은 작동하지 않는다.

대신에 큐브 맵을 사용한다면 어떨까? 큐브 맵은 단지 6개의 면만 있는 환경 데이터를 저장할 수 있기 때문에 큐브 맵의

각면에 전체 장면을 렌더링하고 이를 포인트 라이트의 주변 깊이 값으로 샘플링하는 것이 가능하다.

생성된 깊이 큐브 맵은 방향 벡터로 큐브 맵을 샘플링해 해당 fragment에서 깊이를 얻는 lighting fragment shader로

전달된다. 이미 shadow mapping 강좌에서 설명한 복잡한 것들이 대부분이다.

이 알고리즘을 약간 더 어렵게 만드는 것은 깊이 큐브 맵 생성이다.

 

Generating the depth cubemap

빛의 주변 깊이 값을 큐브 맵으로 만드려면 scene을 각 면에 한 번씩 6번 렌더링해야 한다.

이를 수행하기 위한 하나의 방법은 매번 6개의 서로 다른 view 매트릭스로 장면을 6번 렌더링하고,

매번 다른 큐브 맵면을 프레임 버퍼 오브젝트에 첨부한다.

이는 다음과 같다.

for(unsigned int i = 0; i < 6; i++)
{
    GLenum face = GL_TEXTURE_CUBE_MAP_POSITIVE_X + i;
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, face, depthCubemap, 0);
    BindViewMatrix(lightViewMatrices[i]);
    RenderScene();  
}

이는 하나의 깊이 맵에 많은 렌더 호출이 필요하기 때문에 상당한 비용이 든다.

이 강좌에서는 기하학적 쉐이더에서의 약간의 트릭을 사용하는 대안적인 접근법을 사용한다.

이 기법을 사용하면 단 한번의 렌더링 패스로 깊이 큐브 맵을 작성할 수 있다.

 

먼저 큐브 맵을 만들어야 한다.

unsigned int depthCubemap;
glGenTextures(1, &depthCubemap);

그리고 하나의 큐브 맵 면을 2D 깊이 값 텍스처 이미지로 생성한다.

const unsigned int SHADOW_WIDTH = 1024, SHADOW_HEIGHT = 1024;
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
for (unsigned int i = 0; i < 6; ++i)
        glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_DEPTH_COMPONENT, 
                     SHADOW_WIDTH, SHADOW_HEIGHT, 0, GL_DEPTH_COMPONENT, GL_FLOAT, NULL);  

또한 적합한 텍스처 매개변수를 설정하는 것을 잊어서는 안된다.

glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);  

일반적으로 큐브 맵 텍스처의 한 면을 프레임 버퍼 객체에 첨부하고 프레임 버퍼의 깊이 버퍼 대상을 다른 큐브 덤프면으로

전환할 때마다 장면을 6번 렌더링한다.

한 번의 패스로 모든 면에 렌더할 수 있는 기하 구조 쉐이더를 사용할 것이기 때문에 glFramebufferTexture를 사용해

큐브 맵을 프레임 버퍼의 깊이 첨부로 직접 첨부할 수 있다.

glBindFramebuffer(GL_FRAMEBUFFER, depthMapFBO);
glFramebufferTexture(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, depthCubemap, 0);
glDrawBuffer(GL_NONE);
glReadBuffer(GL_NONE);
glBindFramebuffer(GL_FRAMEBUFFER, 0);

다시 glDrawBuffer와 glReadBuffer에 대한 호출을 주목하라.

깊이 큐브를 생성할 때 깊이 값만 신경써서 OpenGL에게 명시적으로 알려주어야 하므로

이 프레임 버퍼 객체가 컬러 버퍼에 렌더링되지 않는다.

 

omnidirectional shadow map에서는 두 개의 렌더링 패스가 존재한다.

먼저 깊이 맵을 생성하고 두 번째로 일반 렌더 패스에서 깊이 맵을 사용해 씬에 그림자를 만든다.

프레임 버퍼 객체와 큐브 맵을 가지고 진행한 이 과정은 다음과 같다.

// 1. first render to depth cubemap
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 cubemap)
glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
ConfigureShaderAndMatrices();
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
RenderScene();

이 과정은 2D 깊이 텍스처와 달리 큐브 깊이 텍스처를  렌더링하고 사용하지만, 프로세스는 기본 그림자 매핑과 정확히 동일하다.

실제로 모든 빛의 보기 방향에서 장면을 렌더링하기 전에 먼저 적절한 변환 행렬을 계산해야 한다.

 

Light space transform

프레임 버퍼와 큐브 맵을 설정하면 모든 장면의 기하를 빛의 모든 6방향에서 관련 빛 공간으로 변환할 수 있는 방법이 필요하다.

그림자 매핑 강좌와 유사하게 밝은 공간 변환 행렬 T를 필요로 할 것이다.

그러나 이번에는 각 면에 대해 하나씩이다.

 

각각의 광 공간 변환 행렬은 projection과 view 매트릭스를 모두 포함한다.

투영 행렬의 경우 투영 행렬을 사용한다.

광원은 공간의 한 지점을 나타내므로 원근 투영이 가장 적합하다.

각 조명 공간 변환 행렬은 동일한 투영 행렬을 사용한다.

float aspect = (float)SHADOW_WIDTH/(float)SHADOW_HEIGHT;
float near = 1.0f;
float far = 25.0f;
glm::mat4 shadowProj = glm::perspective(glm::radians(90.0f), aspect, near, far); 

glm::perspective의 field of view 매개 변수는 90도까지 설정해야 한다.

이 값을 90도로 설정하면 큐브 맵의 한 면을 올바르게 채울 수 있는 보기 영역이 정확히 충분히 넓어

모든 면이 가장자리에서 서로 올바르게 정렬되도록 한다.

 

투영 행렬은 방향마다 변하지 않으므로 각 변환 행렬에 대해 다시 사용할 수 있다.

방향마다 다른 view 매트릭스가 필요하다.

glm::lookAt을 사용해 6개의 view 방향을 생성한다.

각 방향은 right, left, top, bottom, near, far 순서로 큐브 맵의 단일 방향을 향하고 있다.

std::vector<glm::mat4> shadowTransforms;
shadowTransforms.push_back(shadowProj * 
                 glm::lookAt(lightPos, lightPos + glm::vec3( 1.0, 0.0, 0.0), glm::vec3(0.0,-1.0, 0.0));
shadowTransforms.push_back(shadowProj * 
                 glm::lookAt(lightPos, lightPos + glm::vec3(-1.0, 0.0, 0.0), glm::vec3(0.0,-1.0, 0.0));
shadowTransforms.push_back(shadowProj * 
                 glm::lookAt(lightPos, lightPos + glm::vec3( 0.0, 1.0, 0.0), glm::vec3(0.0, 0.0, 1.0));
shadowTransforms.push_back(shadowProj * 
                 glm::lookAt(lightPos, lightPos + glm::vec3( 0.0,-1.0, 0.0), glm::vec3(0.0, 0.0,-1.0));
shadowTransforms.push_back(shadowProj * 
                 glm::lookAt(lightPos, lightPos + glm::vec3( 0.0, 0.0, 1.0), glm::vec3(0.0,-1.0, 0.0));
shadowTransforms.push_back(shadowProj * 
                 glm::lookAt(lightPos, lightPos + glm::vec3( 0.0, 0.0,-1.0), glm::vec3(0.0,-1.0, 0.0));

여기에서는 6개의 뷰 행렬을 만들고 이를 projection 행렬에 곱해 총 6개의 다른 빛 공간 변환 행렬을 얻는다.

glm::lookAt의 target 매개 변수는 각각 하나의 큐브 맵 면 방향을 조사한다.

이러한 변환 매트릭스는 큐브 맵에 깊이를 렌더링하는 쉐이더로 전송된다.

 

Depth shaders

깊이 큐브 맵에 깊이 값을 렌더링하려면 총 3개의 쉐이더가 필요하다.

즉, 정점 쉐이더와 조각 쉐이더 및 중간의 기하 쉐이더이다.

 

기하 쉐이더는 모든 세계 공간 정점을 6개의 다른 밝은 공간으로 변환하는 책임이 있는 쉐이더이다.

따라서 정점 쉐이더는 단순히 정점을 세계 공간으로 변환하고 이를 기하 쉐이더로 전달한다.

#version 330 core
layout (location = 0) in vec3 aPos;

uniform mat4 model;

void main()
{
    gl_Position = model * vec4(aPos, 1.0);
}  

그 다음 기하 구조 쉐이더는 삼각형 정점과 빛 공간 변환 행렬의 균일한 배열을 입력으로 사용한다.

그러면 형상 쉐이더는 정점을 밝은 공간으로 변환하는 역할을 한다.

이는 흥미로운 부분이기도 하다.

 

기하학 쉐이더에는 gl_Layer라는 built-in 변수가 있는데 이 큐브 페이스는 primitive를 내보낼 큐브 페이스를 지정한다.

그대로 두면 기하 구조 쉐이더는 평범한 것처럼 파이프라인 아래로 기본 요소를 보낸다.

그러나 이 변수를 업데이트할 때 각 프리미티브에 대해 렌더링할 큐브 맵면을 제어할 수 있다.

이는 물론 활성 프레임 버퍼에 큐브 맵 텍스처가 부착된 경우에만 작동한다.

#version 330 core
layout (triangles) in;
layout (triangle_strip, max_vertices=18) out;

uniform mat4 shadowMatrices[6];

out vec4 FragPos; // FragPos from GS (output per emitvertex)

void main()
{
    for(int face = 0; face < 6; ++face)
    {
        gl_Layer = face; // built-in variable that specifies to which face we render.
        for(int i = 0; i < 3; ++i) // for each triangle vertex
        {
            FragPos = gl_in[i].gl_Position;
            gl_Position = shadowMatrices[face] * FragPos;
            EmitVertex();
        }    
        EndPrimitive();
    }
}  

이 형상 쉐이더는 비교적 간단해야 한다.

삼각형을 입력으로 받아 총 6개의 삼각형을 출력한다.(6개의 꼭지점이 18개의 꼭지점에 해당)

main 함수에서 face 정수를 gl_Layer에 저장해 각 면을 출력면으로 지정하는 6개의 큐브면을 반복한다.

그 다음 FragPos에 얼굴의 밝은 공간 변환 행렬을 곱해 각 월드 공간 정점을 관련 빛 공간으로 변환해 각 삼각형을 생성한다.

결과 값인 FragPos 변수를 깊이 값을 계산하는데 필요한 fragment shader에 보냈음을 유념해야 한다.

 

이전 강좌에서는 빈 fragment shader를 사용해 OpenGL에서 깊이 맵의 깊이 값을 계산했었다.

이번에는 각 fragment의 위치와 광원 위치 사이의 직선 거리로 해당 fragment의 깊이를 계산할 것이다.

이렇게 해서 그림자 계산을 좀 더 직관적으로 할 수 있다.

#version 330 core
in vec4 FragPos;

uniform vec3 lightPos;
uniform float far_plane;

void main()
{
    // get distance between fragment and light source
    float lightDistance = length(FragPos.xyz - lightPos);
    
    // map to [0;1] range by dividing by far_plane
    lightDistance = lightDistance / far_plane;
    
    // write this as modified depth
    gl_FragDepth = lightDistance;
}  

fragment shader는 기하 쉐이더에서 FragPos, 빛의 위치 벡터 및 절두 원의 평면 값을 입력으로 받는다.

여기서 fragment와 광원 사이의 거리를 가져와서 [0, 1]범위에 매핑하고 fragment의 깊이 값으로 사용한다.

 

이 쉐이더와 큐브 맵 첨부 프레임 버퍼 오브젝트를 사용해 장면을 렌더링하면 두 번째 패스의 그림자 계산을 위해

채워진 깊이 큐브맵이 생성되어야 한다.

 

Omnidirectional shadow maps

모든 것이 설정되면 실제 omnidirectional shadow를 렌더링할 차례이다.

이 과정은 directional map mapping 강좌와 유사하지만 이번에는 2D 텍스처 대신

큐브맵 텍스처를 깊이 맵으로 바인딩하고 조명 프로젝션의 원거리 평면 변수를 쉐이더에 전달한다.

glViewport(0, 0, SCR_WIDTH, SCR_HEIGHT);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
shader.use();  
// ... send uniforms to shader (including light's far_plane value)
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, depthCubemap);
// ... bind other textures
RenderScene();

여기서 renderScene함수는 장면 중앙의 광원 주위에 흩어져 있는 대형 큐브 룸에서 일부 큐브를 렌더링한다.

 

정점 쉐이더와 fragment shader는 원래의 shadow mapping 쉐이더와 대체로 유사하다.

차이점은 방향 벡터를 사용해 깊이 값을 샘플링할 수 있기 때문에 fragment shader가 더이상 광원 공간에서

fragment의 위치를 필요로하지 않는다는 점이다.

 

이 때문에 정점 쉐이더는 더 이상 위치 벡터를 광원 공간으로 변환할 필요가 없으므로 FragPosLightSpace 변수를 제거할 수 있다.

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

out vec2 TexCoords;

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

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

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

fragment shader의 Blinn-Phong 조명 코드는 이전의 그림자 곱셈과 완전히 같다.

#version 330 core
out vec4 FragColor;

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

uniform sampler2D diffuseTexture;
uniform samplerCube depthMap;

uniform vec3 lightPos;
uniform vec3 viewPos;

uniform float far_plane;

float ShadowCalculation(vec3 fragPos)
{
    [...]
}

void main()
{           
    vec3 color = texture(diffuseTexture, fs_in.TexCoords).rgb;
    vec3 normal = normalize(fs_in.Normal);
    vec3 lightColor = vec3(0.3);
    // ambient
    vec3 ambient = 0.3 * 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);
    vec3 reflectDir = reflect(-lightDir, normal);
    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.FragPos);                      
    vec3 lighting = (ambient + (1.0 - shadow) * (diffuse + specular)) * color;    
    
    FragColor = vec4(lighting, 1.0);
}  

여기 몇 가지 미묘한 차이점이 있다.

조명 코드는 동일하지만 samplerCube 유니폼이 있고,

ShadowCalculation 함수는 밝은 공간에서 fragment 위치 대신 매개 변수로 fragment의 위치를 취한다.

이제 나중에 필요해질 빛의 절두체의 far_plane 값을 포함시킨다.

fragment shader가 끝날 때 fragment에 그림자가 질 경우 1.0이고, 그렇지 않을 때 0.0인 shadow component를

계산한다. 계산된 그림자 구성요소를 사용해 조명의 확산 및 반사 구성 요소에 영향을 준다.

 

크게 다른 점은 2D 텍스처 대신 큐브 맵에서 깊이 값을 샘플링하는 ShadowCalculation 함수의 내용이다.

내용을 단계별로 살펴보자.

첫 번째로 큐브 맵의 깊이를 검색하는 것이다.

이 강좌의 큐브 맵 섹션에서 기억할 수 있듯이, fragment와 광원 위치 사이의 선형 거리로 깊이를 저장했다.

여기서도 비슷한 접근법을 취한다.

float ShadowCalculation(vec3 fragPos)
{
    vec3 fragToLight = fragPos - lightPos; 
    float closestDepth = texture(depthMap, fragToLight).r;
}  

여기서 fragment의 위치와 빛의 위치 사이의 차이 벡터를 취해 그 벡터를 방향 벡터로 사용해서 큐브맵을 샘플링한다.

방향 벡터는 cubemap에서 샘플링할 단위 벡터가 아니어도 되므로 정규화할 필요가 없다.

결과 nearestDepth는 광원과 가장 가까운 가시적인 fragment 사이의 정규화된 깊이 값이다.

 

closestDepth 값은 현재 [0, 1]범위에 있으므로 far_plane과 곱해 [0, far_plane]으로 다시 변환한다.

closestDepth *= far_plane;  

다음으로 큐브 맵에서 깊이 값을 계산하는 방법 때문에 fragToLight의 길이를 취함으로써

쉽게 얻을 수 있는 현재 fragment와 광원 사이의 깊이 값을 검색한다.

float currentDepth = length(fragToLight);  

nearestDepth와 같은 범위의 깊이 값을 반환한다.

 

이제 두 깊이 값을 비교해 어느 것이 더 가깝고 현재 fragment가 그림자에 있는지 여부를 판단할 수 있다.

또한 그림자 기울기를 포함하므로 이전 강좌에서 설명한 것처럼 shadow acne가 발생하지 않는다.

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

전체 ShadowCalculation은 다음과 같다.

float ShadowCalculation(vec3 fragPos)
{
    // get vector between fragment position and light position
    vec3 fragToLight = fragPos - lightPos;
    // use the light to fragment vector to sample from the depth map    
    float closestDepth = texture(depthMap, fragToLight).r;
    // it is currently in linear range between [0,1]. Re-transform back to original value
    closestDepth *= far_plane;
    // now get current linear depth as the length between the fragment and light position
    float currentDepth = length(fragToLight);
    // now test for shadows
    float bias = 0.05; 
    float shadow = currentDepth -  bias > closestDepth ? 1.0 : 0.0;

    return shadow;
}  

이 쉐이더를 사용해 이미 점 그림자로부터 모든 주변 방향으로 꽤 좋은 그림자와 시간을 얻는다.

단순한 장면의 중앙에 위치하는 점 광원을 사용하면 결과는 다음과 같다.

wood.jpg로 렌더링한 결과
wood.png로 렌더링한 결과

 

Visualizing cubemap depth buffer

깊이 맵이 올바르게 구축되었는지를 확인하는 명백한 검사 방법 중 하나를 사용해 일부를 디버깅하는 것이 좋다.

더 이상 2차원 깊이 맵 텍스처가 없기 때문에 깊이 맵을 시각화하는 것이 조금 덜 명확해진다.

 

깊이 버퍼를 시각화하는 간단한 방법은 ShadowCalculation 함수에서 approximized (범위[0, 1]) closetDepth 변수를

가져와서 해당 변수를 다음과 같이 표시하는 것이다.

FragColor = vec4(vec3(closestDepth / far_plane), 1.0);  

결과는 그레이아웃된 장면이고 각 색은 장면의 선형 깊이 값을 나타낸다.

바깥 쪽 벽면에 그림자가 있는 영역을 볼 수도 있다.

모양이 다소 비슷하다면 깊이의 큐브 맵이 제대로 생성되었음을 알 수 있다.

그렇지 않으면 아마도 뭔가 잘못되었거나 [0, far_plane] 범위의 closestDepth를 사용했을 것이다.

 

PCF

omnidirectional shadow map은 전통적인 shadow mapping과 동일한 원칙에 기반하기 때문에 동일한 해상도 종속 아티팩트가 존재한다.

자세히 확대하면 들쭉날쭉한 가장자리를 또 볼 수 있다.

Percentage-closer filtering 또는 PCF를 사용하면 조각 위치 주변의 여러 샘플을 필터링하고 결과를 평균화해

이러한 들쭉날쭉한 가장자리를 부드럽게 처리할 수 있다.

 

앞의 강좌와 동일한 PCF 필터를 사용하고 세 번째 자원을 추가하면 다음을 얻는다.

float shadow  = 0.0;
float bias    = 0.05; 
float samples = 4.0;
float offset  = 0.1;
for(float x = -offset; x < offset; x += offset / (samples * 0.5))
{
    for(float y = -offset; y < offset; y += offset / (samples * 0.5))
    {
        for(float z = -offset; z < offset; z += offset / (samples * 0.5))
        {
            float closestDepth = texture(depthMap, fragToLight + vec3(x, y, z)).r; 
            closestDepth *= far_plane;   // undo mapping [0;1]
            if(currentDepth - bias > closestDepth)
                shadow += 1.0;
        }
    }
}
shadow /= (samples * samples * samples);

이 코드는 전통적인 쉐도우 매핑과 크게 다르지 않다.

여기서는 각 축에 취할 샘플 수를 기반으로 텍스처 오프셋을 동적으로 계산하고

마지막에 평균을 구한 서브 샘플의 양을 3번 샘플링한다.

 

그림자는 이제 훨씬 더 부드럽고 매끈해 보이며 훨씬 더 그럴듯한 결과를 제공한다.

그러나 샘플을 4.0으로 설정하면 각 fragment들이 총 64샘플이므로 과하게 많다.

 

이 샘플들의 대부분은 원래 방향 벡터에 가깝게 샘플링한다는 점에서 중복되므로 샘플 방향 벡터의 수직 방향으로

샘플링하는 것이 더 합리적일 수도 있다. 그러나 어떤 하위 방향이 중복되는지 파악하기 쉬운 방법이 없기 때문에

이 작업은 쉽지 않다.

여기서 사용할 수 있는 한 가지 트릭은 모두 대략 분리 가능한 오프셋 방향 배열을 취하는 것이다.

각각은 완전히 다른 방향을 가리키며 서로 가깝게 있는 하위 방향의 수를 줄인다.

아래에는 최대 20개의 오프셋 방향이 있다.

vec3 sampleOffsetDirections[20] = vec3[]
(
   vec3( 1,  1,  1), vec3( 1, -1,  1), vec3(-1, -1,  1), vec3(-1,  1,  1), 
   vec3( 1,  1, -1), vec3( 1, -1, -1), vec3(-1, -1, -1), vec3(-1,  1, -1),
   vec3( 1,  1,  0), vec3( 1, -1,  0), vec3(-1, -1,  0), vec3(-1,  1,  0),
   vec3( 1,  0,  1), vec3(-1,  0,  1), vec3( 1,  0, -1), vec3(-1,  0, -1),
   vec3( 0,  1,  1), vec3( 0, -1,  1), vec3( 0, -1, -1), vec3( 0,  1, -1)
);   

그 다음 첫 번째 PCF 알고리즘과 시각적으로 비슷한 결과를 얻을 때 훨씬 적은 샘플이 필요하다는 것이다.

float shadow = 0.0;
float bias   = 0.15;
int samples  = 20;
float viewDistance = length(viewPos - fragPos);
float diskRadius = 0.05;
for(int i = 0; i < samples; ++i)
{
    float closestDepth = texture(depthMap, fragToLight + sampleOffsetDirections[i] * diskRadius).r;
    closestDepth *= far_plane;   // undo mapping [0;1]
    if(currentDepth - bias > closestDepth)
        shadow += 1.0;
}
shadow /= float(samples);  

여기서 원래의 fragToLight 방향 벡터 주위의 일정 diskRadius에 오프셋을 추가해 큐브 맵에서 샘플링한다.

 

여기에 적용할 수 있는 또 다른 흥미로운 트릭은 뷰어가 fragment에서 얼마나 멀리 떨어져 있는지에 따라

diskRadius를 변경할 수 있다는 것이다.

이렇게 하면 뷰어까찌의 거리에 따라 오프셋 반경을 늘릴 수 있다.

그림자가 멀리 떨어져 있을 때 부드럽고 가까이에 있을 때 날카롭게 만든다.

float diskRadius = (1.0 + (viewDistance / far_plane)) / 25.0;  

이 PCF 알고리즘의 결과는 부드러운 그림자의 결과와 마찬가지로 양호한 결과를 제공한다.

물론 각 샘플에 추가되는 기울기는 컨텍스트를 기반으로 하며 작업하는 장면에 따라 항상 조정되어야 한다.