현실 세계의 거의 모든 장면에는 그림자가 있으므로 그림자 생성은 컴퓨터 그래픽에서 필수 불가결한 구성 요소다.
또한 그림자는 장면에서 물체 간의 공간 관계를 이해하는 데 도움이 된다.
15.1 두 단계 렌더링
쉐도우 매핑 알고리즘은 두 번의 렌더링 패스(rendering pass)를 통해 수행된다.
첫 번재 패스 - 실제 렌더링 이루어지지 않음
- 광원의 시점에서 장면을 렌더링
- 쉐도우맵(shadow map)이라는 특수한 텍스처 생성
- 물체의 표면을 균일하게 샘플하여, 각 샘플점 $p$마다 광원가지의 거리인 $z$를 쉐도우맵에 저장
- 광원에서 본 3차원 장면의 깊이를 저장하므로, 쉐도우맵을 광원 기준의 깊이맵(depth map)이라고도 한다.
두 번째 패스 - 실제 렌더링
- 쉐도우맵을 사용하여 그림자를 생성한다.
- 카메라 위치로부터 장면을 렌더링하는 동안, 각 프래그먼트에서부터 광원까지의 거리 $d$를 쉐도우맵에 저장된 깊이 $z$(월드 공간에서 광원까지의 거리를 저장하는 것으로 설명했지만, 이해를 돕기 위함이고, 실제 구현에서는 스크린 공간에서 정규화된 값을 저장하게 된다.) 비교한다.
- $d>z$인 경우, 픽셀은 빛이 도달할 수 없는 장면의 지점을 나타내므로 그림자 속에 있는 것으로 결정된다.
- 그 외의 경우, 그림자 속에 있지 않은 것으로 결정되므로 충분히 빛이 도달한다.
쉐도우 매핑 알고리즘을 그대로 구현할 경우, 여러 문제가 발생한다.
위 사진을 보면, 완전하게 빛을 받는 지역에 자잘한 그림자가 섞여 있다.
두 단계 렌더링의 두 번째 패스 개념 설명에서 보인 그림을 보면 프래그먼트 $f$의 월드 공간 점 $q$는 쉐도우 맵을 생성할 때 샘플한 점과 일치했었다. 하지만 실제 이 같은 경우는 거의 발생하지 않는다.
즉, 첫 번째 패스에서 샘플한 점과 두 번째 패스에서 샘플한 점은 대개의 경우 불일치한다.
위 사진에서 $q_1$은 첫 번째 패스에서 쉐도우맵을 구성하기 위하여 샘플한 어떤 점과도 일치하지 않는다.
쉐도우맵은 텍스처의 한 종류이므로 이를 어떻게 필터링할 것인지 정해야 한다.
따라서 책에서는 근접점 샘플링을 사용한다고 가정한다.
그러면 $q_1$의 경우, 쉐도우맵에서 $z_1$을 읽어온다. 이는 $q_1$과 광원 사이의 거리 $d_1$보다 작기 때문에 $q_1$은 그림자에 속하는 점으로 판정된다. 이는 잘못된 판정이다.
반면, $q_2$의 경우, 쉐도우 맵에서 $z_2$를 읽어오므로, $q_2$는 빛을 받는다고 판정된다. 이는 옳은 판정이다.
이처럼 완전하게 빛을 받는 지역임에도 일부 프래그먼트는 빛을 못 받는 것으로 판정되어 쉐도우 매핑 알고리즘을 그대로 구현했을 때, 자잘한 그림자가 생기게 된다.
두 번째 패스에서 샘플한 점들을 광원 쪽으로 약간 이동하면 문제를 해결할 수 있다.
즉, 광원가지의 거리 $d$에서 일정한 값을 빼는 것이다. 이 값을 바이어스(bias)라 한다.
위 그림에서는 $d_1$에서 바이어스를 빼서 $d_1'$를 만든 후 이를 $z_1$과 비교하였다.
$d_1'$가 $z_1$보다 작으므로 $q_1$은 빛을 받는 것으로 판정된다.
쉐도우 매핑에서는 적절한 바이어스를 설정하는 것이 중요하다.
바이어스가 너무 작을 경우, 불필요한 자잘한 그림자를 완벽히 제거할 수 없다.
바이어스가 너무 큰 경우, 그림자가 과도하게 축소될 수 있다. 그늘져야 할 부분이 빛을 받는 것으로 판정된다.
바이어스는 대개 몇 번의 수정을 거쳐 적절한 값으로 설정된다.
15.2 쉐도우맵 필터링
월드 공간 점 $q_1$과 $q_2$를 보면, 쉐도우맵으로 투영(project)되어 두 텍셀 $p_1$과 $p_2$ 사이에 놓이게 된다.
근접점 샘플링으로 쉐도우맵을 필터링하면, $q_1$은 $p_1$과 비교되어 그림자 지는 것으로 판정되고, $q_2$는 $p_2$와 비교되어 빛을 받는 것으로 판정된다.
즉, 하나의 프래그먼트는 완전히 빛을 받거나 완전히 그늘지거나 둘 중 하나로 판정될 뿐, 다른 여지는 없다.
따라서, 위 사진의 우측처럼 거친 윤곽선을 가진 그림자가 생성된다.
위 사진의 좌측은 쉐도우맵에 투영된 프래그먼트 $q$를 보여준다.
$q$를 둘러싼 네 개의 텍셀의 깊이 값은 쉐도우맵에서 읽어온 것이다(기본적으로 쉐도우맵 텍셀은 [0,1] 범위의 실수값을 가진다. 설명의 편의를 위해 정수값을 사용한 것).
이 깊이 값을 겹선형보간하면 64가 된다. 만약 $q$의 깊이가 80이라면, $q$는 그늘진 것으로 판정된다.
즉, 완전히 빛을 받거나 완전히 그늘지거나 둘 중 하나로 판정되는 문제는 여전히 존재한다.
해결책은 네 개의 텍셀 각각에 대해 $q$의 가시성(visibility)을 결정한 다음 보간하는 것이다.
위 사진의 우측에서, 왼쪽 위의 텍셀만 고려하면 $q$에는 그림자가 맺히는 것으로 판정된다. 즉, 해당 텍셀에 대한 $q$의 가시성은 0이다.
반면, 나머지 세 개의 텍셀에 대해서 $q$는 빛을 받는 것으로 판정된다. 즉, 이들에 대한 $q$의 가시성은 1이다.
네 개의 가시성 값을 겹선형보간하면 $q$의 가시성은 0.58로 계산된다. 이 값은 $q$가 빛을 받는 정도를 나타낸다.
즉, 완전히 빛을 받거나 완전히 그늘지거나 둘 중 하나를 결정하는 것이 대신, [0, 1] 범위 안의 값을 취하게 된다.
0에 가까우면 어둡게, 1에 가까우면 밝게 처리된다.
결과적으로 그림자의 윤곽선이 겹선형보간을 이용했을 때보다 부드러워졌다.
이처럼, 쉐도우맵에서 여러 개의 텍셀을 참조하여 이들에 대한 픽셀의 가시성을 결정하고 그 결과를 결합하는 기법을 일반적으로 PCF(percentage closer filtering)라고 부른다.
15.3 쉐도우 매핑을 위한 GL 프로그램과 쉐이더
쉐도우매핑의 첫 번째 렌더링 패스에서는 광원에서 보이는 표면을 샘플하여 쉐도우맵을 생성한다.
이를 위해서 카메라를 광원의 위치에 놓고 3차원 장면을 렌더링한다. 이때 라이팅과 텍스처링은 전혀 수행하지 않는다.
다만, 최종적으로 얻은 z-버퍼를 쉐도우맵으로 취한다.
두 번째 패스에서는 실제 카메라 관점에서 통상적인 렌더링을 수행하면서 쉐도우맵을 이용한다.
15.3.1 첫 번째 패스의 쉐이더
첫 번째 패스에서는, 모든 3차원 정점이 월드 변환을 통해 월드 공간으로 변환된 후, 카메라 파라미터가 광원 기준으로 설정된다(광원 위치에 EYE가 놓인다).
이러한 카메라 파라미터에 의해 정의된 공간은 카메라 공간 대신 빛 공간(light space)이라 부른다.
월드 공간 정점이 lightViewMat에 의해 빛 공간으로 변환되면, lightProjMat가 빛 공간 정점을 '광원 기준의 클립 공간'으로 변환한다.
정점 쉐이더는 이 말고는 아무것도 출력하지 않는다. gl_Position은 래스터라이저로 들어가 원근 나눗셈된다.
$p$는 광원에서 보이는 정점을 의미하고, 원근 나눗셈 결과 NDC로 표현된 2 x 2 x 2 크기의 정육면체 뷰 볼륨 안에 놓인다.
정육면체 뷰 볼륨은 스크린 공간 뷰포트로 변환된다.
스크린 공간 삼각형은 스캔 전환에 의해 프래그먼트로 나눠지는데, 실은 이러한 과정에서 보간할 것은 아무것도 없다.
왜냐하면 정점 쉐이더가 gl_Position 말고는 아무 것도 출력하지 않았기 때문이다.
프래그먼트 쉐이더는 아무런 입력 변수가 없고 메인 함수는 비어 있다.
쉐도우 매핑은 첫 번째 패스에서 프래그먼트의 색상을 결정하지 않기 때문이다.
프래그먼트 쉐이더는 아무것도 출력하지 않지만, 각 프래그먼트의 스크린 공간 좌표는 GPU 파이프라인의 다음 단계인 출력 병합기로 건네진다. 따라서 출력 병합기는 이를 이용해 z-버퍼링을 수행한다.
이렇게 첫 번째 패스가 끝나면, z-버퍼에는 광원에서 보이는 점들의 깊이 값이 저장된다. 이것을 쉐도우 맵으로 취한다.
$z_p$는 광원에서 보이는 점 $p$의 스크린 공간 깊이 값을 의미한다.
뷰포트의 $z$범위 [minZ, maxZ]가 [0, 1]로 설정되었다고 가정하면, 쉐도우맵의 모든 텍셀은 [0, 1] 범위 안의 값을 가진다.
두 단계 렌더링의 개념 설명에 나오는 그림에서는 쉐도우맵이 월드 공간에서 광원까지의 거리를 저장하고 있는 것으로 설명했지만, 이는 이해를 돕기 위한 것이었고, 실제 구현에서는 이렇게 스크린 공간에서 정규화된 값을 저장하게 된다.
15.3.2 프레임버퍼 오브젝트
프레임버퍼 오브젝트를 위한 GL 프로그램
#define SHADOW_MAP_SIZE 1024
GLuint texture;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT16,
SHADOW_MAP_SIZE, SHADOW_MAP_SIZE, 0,
GL_DEPTH_COMPONENT, GL_UNSIFNED_SHORT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE,
GL_COMPARE_REF_TO_TEXTURE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);
GLuint fbo;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
glFramebufferTexture2D(GL_FRAMEBUFFER,
GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, texture, 0);
쉐도우 매핑의 첫 번째 패스에서, 렌더링은 실제 스크린 대신 텍스처에 이뤄진다.
텍스처에 렌더링(render-to-texture)하기 위해서 GL 프로그램은 먼저 쉐도우맵으로 사용할 텍스처를 확보해야 한다.
컬러, 버퍼, 스텐실 버퍼로 구성된 프레임 버퍼(framebuffer)가 출력 병합기에 있다.
실제 스크린에 렌더링할 때에는 프레임 버퍼를 사용하지만, 텍스처에 렌더링하기 위해서는 프레임 버퍼 오브젝트(framebuffer object, FBO)를 사용해야 한다.
버퍼 오브젝트이므로 생성하고 바인딩하고 데이터를 제공하는 과정을 마찬가지로 필요하다.
프레임 버퍼와 마찬가지로 FBO는 컬러, 깊이, 스텐실 세 가지로 구성되는데, 쉐도우맵을 위해 확보한 텍스처 오브젝트(texture)는 FBO의 깊이 요소에 붙여(attach)져야 한다. 따라서 마지막에 GL_DEPTH_ATTACHMENT를 사용한다.
15.3.3 두 번째 패스의 쉐이더
두 번째 패스에서는 실제 카메라 관점에서 통상적인 렌더링을 수행한다.
위 그림 (a)에서 프래그먼트 $f$에 해당하는 월드 공간의 점 $q$를 보면, 여기에 그림자가 생성되는지 결정하기 위해서, 첫 번째 패스에서 샘플한 점 $p$와 깊이 비교를 수행한다(쉐도우맵 필터링을 위해 근접점 샘플링이 설정되었고, 쉐도우맵 텍셀 중 $q$와 가장 가까운 것이 $p$라 가정).
깊이 비교를 위해서, $q$는 $p$가 밟았던 경로를 그대로 따라야 한다. 즉, $q$를 '광원 기준의 클립 공간'으로 변환하고, 원근 나눗셈을 통해 2 x 2 x 2 크기의 정육면체 뷰 볼륨 안으로 옮긴다.
(a)에서 현재 $q$의 깊이 값은 [-1, 1] 범위에 있는데, 비교할 대상인 $p$의 깊이(z_p)는 [0, 1] 범위에 있다.
따라서 $q$의 깊이 값이 [0, 1] 범위에 놓이도록 전환해야 하는데, 그 결과를 $d_q$라 표현하자.
2 x 2 x 2 크기 뷰 볼륨의 단면에서 굵은 선으로 표시한 면은 2 x 2 크기의 정사각형이다. 그림 (b)는 이 정사각형을 그린 것
$z_p$와 $d_q$를 비교하려면, 먼저 쉐도우맵에 저장된 $z_p$를 읽어와야 한다.
이를 위해서 텍스처 좌표 $\left( s, t \right)$를 사용하는데, $s$와 $t$는 모두 [0, 1] 범위에 있다.
반면, 현재 $q$의 $\left( x, y \right)$ 좌표 범위는 [-1, 1]인데, 그림 (b)에서 보이는 것처럶 $q$의 $\left( x, y \right)$ 좌표를 [0, 1] 범위로 전환한다면 이를 $\left(s, t \right)$로 사용해 $z_p$를 읽어올 수 있다.
$q$를 '광원 기준의 클립 공간'으로 변환하여 $\left(x, y, z, w \right)$ 좌표를 얻은 다음, 아래에 $\rightarrow$로 표기한 연산 두 가지를 수행한다.
첫 번째 연산은 원근 나눗셈이며, 그 결과 $x/w$, $y/w$, $z/w$는 모두 [-1, 1] 범위에 놓인다.
두 번째 연산은 이를 [0, 1] 범위로 전환하는 것이다. 그 결과를 $\left( s, t, d_q \right)$로 표현했는데, $\left(s, t \right)$
는 쉐도우맵에서 $z_p$를 읽어오는 데 사용되고, 그렇게 읽어온 $z_p$는 $d_q$와 비교되는 것이다.
효율적인 쉐이더 프로그래밍을 위해 위 수식을 아래와 같이 고친다.
첫 번째 연산은 정점 쉐이더가, 두 번째 연산은 프래그먼트 쉐이더가 수행할 것이다.
두번째 연산은 동차 좌표를 $w$로 나누는 것이다.
쉐도우 매핑을 위한 두 번째 패스의 정점 쉐이더
#version 300 es
uniform mat4 worldMat, viewMat, projMat;
uniform mat4 lightViewMat, lightProjMat;
layout(location = 0) in vec3 position;
layout(location = 1) in vec3 normal;
layout(location = 2) in vec3 texCoord;
out vec3 v_normal, v_light;
out vec2 v_texCoord;
out vec4 v_shadowCoord;
const mat4 tMat = mat4(
0.5, 0.0, 0.0, 0.0,
0.0, 0.5, 0.0, 0.0,
0.0, 0.0, 0.5, 0.0,
0.5, 0.5, 0.5, 1.0
);
void main() {
v_normal = normalize(transpose(inverse(mat3(worldMat))) * normal);
vec3 worldPos = (worldMat * vec4(position, 1.0)).xyz;
v_light = normalize(lightPos - worldPos);
v_texCoord = texCoord;
// for shadow map access and depth comparision
v_shadowCoord = tMat * lightProjMat * lightViewMat * vec4(worldPos, 1.0);
gl_Position = projMat * viewMat * vec4(worldPos, 1.0);
}
월드 공간 정점 worldPos는 lightViewMat와 lightProjMat에 의해 '광원 기준의 클립 공간'의 점 $\left(x,y,z,w \right)$로 변환
tMat은 쉐이더 프로그래밍을 위해 고친 수식의 첫 번째 연산에 해당한다.
이 4차원 벡터는 v_shadowCoord라는 이름으로 출력된다.
메인 함수는 gl_Position을 리턴하는데, 이는 렌더링을 위한 통상적인 GPU 파이프라인을 거친다.
쉐도우 매핑을 위한 두 번째 패스의 프래그먼트 쉐이더
#version 300 es
precision mediump float;
precision mediump sampler2DShadow;
uniform sampler2D colorMap;
uniform sampler2DShadow shadowMap;
uniform vec3 srcDiff;
in vec3 v_normal, v_light;
in vec2 v_texCoord;
in vec4 v_shadowCoord;
layout(location = 0) out vec4 fragColor;
void main() {
vec3 normal = normalize(v_normal);
vec3 light = normalize(v_light);
// diffuse term
vec3 matDiff = texture(colorMap, v_texCoord).rgb;
vec3 diff = max(dot(normal, light), 0.0) * srcDiff * matDiff;
float visibility = textureProj(shadowMap, v_shadowCoord);
fragColor = vec4(visibility * diff, 1.0);
}
shadowMap은 내장 함수 textureProj가 처리하는데, 투영(projection) 방식으로 텍스처를 처리한다.
textureProj의 첫 번째 파라미터가 쉐도우맵이라면, 두 번째 파라미터는 반드시 4차원 벡터여야 한다.
두 번째 파라미터로 받은 v_shadowCoord는 정점 쉐이더가 출력한 것인데, 이는 먼저 투영을 거친다.
즉, 모든 원소가 네 번째 원소로 나눠진다. 그 결과는 $\left( s, t, d_q\right)$이다.
이 중 $\left( s, t \right)$는 쉐도우맵에서 $z_p$를 읽어오는 데 사용되고, 세 번째 원소 $d_q$는 쉐도우맵에서 읽어온 $z_p$와 비교되는 것이다.
프레임버퍼 오브젝트를 위한 GL 프로그램에서 GL_COMPARE_REF_TO_TEXTURE를 사용해 깊이 비교를 활성화 하는데, 여기서 REF는 프래그먼트를, TEXTURE는 텍셀을 의미한다.
GL_TEXTURE_COMPARE_FUNC로 GL_LEQUAL이 지정되었는데, 프래그먼트의 깊이 값이 텍셀의 깊이 값보다 '작거나 같으면(LEQUAL)' 1(빛을 받음)을, 아니면 0(그림자 짐)을 리턴하게 된다. (프래그먼트의 깊이 값보다 텍셀의 깊이 값이 작으면 그림자 진다)
그 후에는 프래그먼트 쉐이더의 textureProj가 PCF를 수행하도록 한다.
즉, 네 개의 텍셀과 비교를 통해 리턴된 0과 1 값들을 선형보간하여 visibility를 결정한다.
이는 [0, 1] 범위의 실수값인데, 코드에서 디퓨즈 색상(diff)과 곱해진다.
바이어스 기반 쉐도우 매핑을 위한 두 번째 패스의 프래그먼트 쉐이더
#version 300 es
precision mediump float;
precision mediump sampler2DShadow;
uniform sampler2D colorMap;
uniform sampler2DShadow shadowMap;
uniform vec3 srcDiff;
in vec3 v_normal, v_light;
in vec2 v_texCoord;
in vec4 v_shadowCoord;
layout(location = 0) out vec4 fragColor;
const float offset = 0.005; // 바이어스
void main() {
vec3 normal = normalize(v_normal);
vec3 light = normalize(v_light);
// diffuse term
vec3 matDiff = texture(colorMap, v_texCoord).rgb;
vec3 diff = max(dot(normal, light), 0.0) * srcDiff * matDiff;
// visibility + bias
vec4 offsetVec = vec4(0.0, 0.0, offset * v_shadowCoord.w, 0.0);
float visibility = textureProj(shadowMap, v_shadowCoord - offsetVec);
fragColor = vec4(visibility * diff, 1.0);
}
15.4 하드 쉐도우와 소프트 쉐도우
하드 쉐도우(hard shadow)
- 점 광원(point light)에서 나오는 빛을 받는 지역과 받지 않는 지역이 명확하게 구분되어 만들어진 그림자
영역 광원(area light)
- '면적'을 가진 광원
- 소프트 쉐도우(soft shadow)를 생성
그림 (a)를 보면, 완전하게 빛을 받는 지역과 완전하게 그늘진 지역 사이에 '부분적으로 그늘진' 지역이 있다.
이는 반그림자, 영문으로 penumbra라고 부르는데, 여기에 속하는 표면의 각 점은 광원에서 나오는 빛의 일부만을 받는다.
따라서 완전하게 빛을 받는 지역과 완전하게 그늘진 지역을 부드럽게 이어준다.
반그림자 지역에 속하는 표면 점에 대해서는 '밝기 정도'를 계산해야 한다.
이는 보통 그 점에서 영역 광원이 얼마나 많이 보이는지 측정하여 결정한다.
그림 (b)의 $q_1$에서는 영역 광원이 많이 안보이지만, 그림 (c)의 $q_2$에서는 보다 넓은 부분을 볼 수 있다.
따라서 $q_2$는 더 큰 '밝기 정도'를 할당 받을 것이다.
이전에 윤곽선을 부드럽게 만들기 위해 가시성을 보간한 것은 소프트 쉐도우가 아니라 앤티에일리어싱(anti-aliasing)처리된 하드 쉐도우다.
출처
[OpenGL ES를 이용한 3차원 컴퓨터 그래픽스 입문]을 보고 공부하고 정리한 내용
'공부 > 컴퓨터 그래픽스' 카테고리의 다른 글
OpenGL ES를 이용한 3차원 컴퓨터 그래픽스 입문 Chapter 17 매개변수 곡선과 곡면 (0) | 2024.07.19 |
---|---|
OpenGL ES를 이용한 3차원 컴퓨터 그래픽스 입문 Chapter 16 전역 조명과 텍스처 (0) | 2024.07.19 |
OpenGL ES를 이용한 3차원 컴퓨터 그래픽스 입문 Chapter 14 노멀 매핑 (0) | 2024.07.19 |
OpenGL ES를 이용한 3차원 컴퓨터 그래픽스 입문 Chapter 13 캐릭터 애니메이션 (0) | 2024.07.19 |
OpenGL ES를 이용한 3차원 컴퓨터 그래픽스 입문 Chapter 12 스크린 물체 조작 (0) | 2024.07.12 |