Skip to content

깊이 테스트

좌표계 챕터에서는 3D 컨테이너를 렌더링하고 깊이 버퍼를 사용하여 삼각형이 다른 삼각형 뒤에 있어야 하는데 앞에 렌더링되는 것을 방지했습니다. 이번 챕터에서는 깊이 버퍼(또는 z-버퍼)에 저장되는 깊이 값과 프래그먼트가 앞에 있는지 여부를 판단하는 방법에 대해 좀 더 자세히 살펴보겠습니다.

깊이 버퍼는 색상 버퍼(모든 프래그먼트 색상, 즉 시각적 출력을 저장하는 버퍼)와 마찬가지로 프래그먼트별 정보를 저장하며, 너비와 높이가 색상 버퍼와 동일합니다. 깊이 버퍼는 윈도우 시스템에 의해 자동으로 생성되며, 깊이 값을 16비트, 24비트 또는 32비트 부동 소수점 값으로 저장합니다. 대부분의 시스템에서는 24비트 정밀도의 깊이 버퍼를 볼 수 있습니다.

깊이 테스트가 활성화되면 OpenGL은 프래그먼트의 깊이 값을 깊이 버퍼의 내용과 비교하여 테스트합니다. OpenGL은 깊이 테스트를 수행하고, 테스트가 통과되면 프래그먼트를 렌더링하고 깊이 버퍼를 새로운 깊이 값으로 업데이트합니다. 깊이 테스트에 실패하면 프래그먼트는 폐기됩니다.

깊이 테스트는 프래그먼트 셰이더 실행 후(그리고 다음 장에서 다룰 스텐실 테스트 후) 화면 공간에서 수행됩니다. 화면 공간 좌표는 OpenGL의 glViewport 함수로 정의된 뷰포트와 직접적인 관련이 있으며, 프래그먼트 셰이더에서 GLSL의 내장 변수 gl_FragCoord를 통해 접근할 수 있습니다. gl_FragCoord의 x 및 y 성분은 프래그먼트의 화면 공간 좌표를 나타냅니다(0,0은 왼쪽 아래 모서리). gl_FragCoord 변수에는 프래그먼트의 깊이 값을 포함하는 z 성분도 있습니다. 이 z 값은 깊이 버퍼의 내용과 비교되는 값입니다.

오늘날 대부분의 GPU는 조기 깊이 테스트(Early Depth Testing)라는 하드웨어 기능을 지원합니다. 조기 깊이 테스트를 통해 프래그먼트 셰이더가 실행되기 전에 깊이 테스트를 실행할 수 있습니다. 프래그먼트가 다른 객체 뒤에 가려져 보이지 않을 것이 분명해지면 해당 프래그먼트를 조기에 폐기할 수 있습니다.

프래그먼트 셰이더는 일반적으로 비용이 많이 들기 때문에 가능한 한 실행을 피해야 합니다. 초기 깊이 테스트를 위한 프래그먼트 셰이더의 제약 조건은 프래그먼트의 깊이 값에 쓰기를 하지 않는 것입니다. 프래그먼트 셰이더가 깊이 값에 쓰기를 하면 초기 깊이 테스트가 불가능해집니다. OpenGL이 깊이 값을 미리 파악할 수 없기 때문입니다.

깊이 테스트는 기본적으로 비활성화되어 있으므로 깊이 테스트를 활성화하려면 GL_DEPTH_TEST 옵션을 사용해야 합니다.

glEnable(GL_DEPTH_TEST);  

깊이 테스트를 활성화하면 OpenGL은 프래그먼트가 깊이 테스트를 통과하면 해당 프래그먼트의 z 값을 깊이 버퍼에 자동으로 저장하고, 테스트에 실패하면 프래그먼트를 그에 따라 폐기합니다. 깊이 테스트를 활성화한 경우, GL_DEPTH_BUFFER_BIT 함수를 사용하여 매 프레임 전에 깊이 버퍼를 지워야 합니다. 그렇지 않으면 이전 프레임의 깊이 값이 계속 사용됩니다.

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);  

특정 상황에서는 모든 프래그먼트에 대해 깊이 테스트를 수행하고 그에 따라 프래그먼트를 폐기하지만 깊이 버퍼는 업데이트하지 않으려는 경우가 있습니다. 기본적으로 (일시적으로) 읽기 전용 깊이 버퍼를 사용하는 것입니다. OpenGL에서는 깊이 마스크를 GL_FALSE로 설정하여 깊이 버퍼에 쓰기를 비활성화할 수 있습니다.

glDepthMask(GL_FALSE); 

참고로, 이는 깊이 테스트가 활성화된 경우에만 효과가 있습니다.

심층 테스트 기능

OpenGL은 깊이 테스트에 사용하는 비교 연산자를 수정할 수 있도록 해줍니다. 이를 통해 OpenGL이 프래그먼트를 통과시키거나 버리는 시점, 그리고 깊이 버퍼를 업데이트하는 시점을 제어할 수 있습니다. 비교 연산자(또는 깊이 함수)는 glDepthFunc를 호출하여 설정할 수 있습니다.

glDepthFunc(GL_LESS);  

이 함수는 아래 표에 나열된 여러 비교 연산자를 허용합니다.

기능 설명
GL_ALWAYS 깊이 테스트는 항상 통과합니다.
GL_NEVER 깊이 테스트는 절대 통과되지 못합니다.
GL_LESS 프래그먼트의 깊이 값이 저장된 깊이 값보다 작으면 통과합니다.
GL_EQUAL 프래그먼트의 깊이 값이 저장된 깊이 값과 같으면 통과합니다.
GL_LEQUAL 프래그먼트의 깊이 값이 저장된 깊이 값보다 작거나 같으면 통과합니다.
GL_GREATER 프래그먼트의 깊이 값이 저장된 깊이 값과 같지 않으면 통과합니다.
GL_GEQUAL 프래그먼트의 깊이 값이 저장된 깊이 값보다 크거나 같으면 통과합니다.

기본적으로 깊이 함수 GL_LESS가 사용되며, 이 함수는 현재 깊이 버퍼의 값보다 크거나 같은 깊이 값을 가진 모든 프래그먼트를 버립니다.

깊이 함수를 변경했을 때 시각적 출력에 어떤 영향을 미치는지 보여드리겠습니다. 조명이 없는 텍스처가 적용된 바닥 위에 텍스처가 적용된 두 개의 큐브가 놓인 기본적인 장면을 표시하는 새로운 코드 설정을 사용하겠습니다. 소스 코드는 여기에서 확인할 수 있습니다.

소스 코드 내에서 depth 함수를 GL_ALWAYS로 변경했습니다.

glEnable(GL_DEPTH_TEST);
glDepthFunc(GL_ALWAYS); 

이는 깊이 테스트를 활성화하지 않았을 때와 동일한 동작을 시뮬레이션합니다. 깊이 테스트는 항상 통과하므로 마지막으로 그려진 프래그먼트가 이전에 그려진 프래그먼트보다 앞에 렌더링됩니다. 실제로는 맨 앞에 있어야 하는데 말이죠. 바닥면을 마지막으로 그렸기 때문에 바닥면의 프래그먼트가 컨테이너에 이전에 그려진 모든 프래그먼트를 덮어씁니다.

모든 설정을 GL_LESS로 되돌리면 우리가 익숙한 유형의 장면이 나타납니다.

깊이 값 정밀도

깊이 버퍼는 0.0에서 1.0 사이의 깊이 값을 저장하며, 뷰어에서 보이는 장면 내 모든 객체의 z 값과 비교합니다. 뷰 공간에서의 이러한 z 값은 투영 절두체의 근거리 평면과 원거리 평면 사이의 임의의 값을 가질 수 있습니다. 따라서 이러한 뷰 공간의 z 값을 [0,1] 범위로 변환하는 방법이 필요하며, 한 가지 방법은 선형 변환입니다. 다음 (선형) 방정식은 z 값을 0.0에서 1.0 사이의 깊이 값으로 변환합니다.

\[ \begin{equation} F_{depth} = \frac{z - near}{far - near} \end{equation} \]

여기서 \(near\)와 \(far\)는 가시 절두체를 설정하기 위해 투영 행렬에 제공하는 근접 및 원거리 값입니다(좌표계 참조). 이 방정식은 절두체 내의 깊이 값 z를 입력받아 [0,1] 범위로 변환합니다. z 값과 해당 깊이 값 사이의 관계는 다음 그래프에 나와 있습니다.

참고로, 모든 방정식은 물체가 가까이 있을 때는 깊이 값이 0.0에 가깝고, 물체가 원거리 평면에 가까울 때는 깊이 값이 1.0에 가깝습니다.

하지만 실제로는 이와 같은 선형 깊이 버퍼는 거의 사용되지 않습니다. 투영 특성 때문에 1/z에 비례하는 비선형 깊이 방정식이 사용됩니다. 그 결과 z 값이 작을 때는 매우 높은 정밀도를 얻고 z 값이 클 때는 정밀도가 크게 떨어집니다.

비선형 함수는 1/z에 비례하므로, z값이 1.0에서 2.0 사이이면 깊이 값은 1.0에서 0.5 사이가 되는데, 이는 [0,1] 범위의 절반에 해당하므로 작은 z값에서 매우 높은 정밀도를 얻을 수 있습니다. z값이 50.0에서 100.0 사이이면 [0,1] 범위의 2%밖에 되지 않습니다. 근거리와 원거리를 모두 고려한 이러한 방정식은 다음과 같습니다.

\[ \begin{equation} F_{depth} = \frac{1/z - 1/near}{1/far - 1/near} \end{equation} \]

이 방정식이 정확히 무엇을 의미하는지 잘 모르시더라도 걱정하지 마세요. 중요한 것은 깊이 버퍼의 값이 클립 공간에서 선형적이지 않다는 점입니다(투영 행렬이 적용되기 전에는 뷰 공간에서 선형적입니다). 깊이 버퍼의 값이 0.5라고 해서 픽셀의 z값이 절두체의 중간에 있다는 뜻은 아닙니다. 실제로 정점의 z값은 근접 평면에 매우 가깝습니다! z값과 결과적인 깊이 버퍼 값 사이의 비선형 관계는 다음 그래프에서 확인할 수 있습니다.

보시다시피, 깊이 값은 작은 z 값에 의해 크게 결정되며, 이를 통해 가까운 객체에 대해 높은 깊이 정밀도를 얻을 수 있습니다. (뷰어의 관점에서) z 값을 변환하는 방정식은 투영 행렬 내에 포함되어 있으므로, 정점 좌표를 뷰 공간에서 클립 공간으로, 그리고 다시 화면 공간으로 변환할 때 비선형 방정식이 적용됩니다.

이 비선형 방정식의 영향은 깊이 버퍼를 시각화하려고 할 때 금방 드러납니다.

깊이 버퍼 시각화

프래그먼트 셰이더에 내장된 gl_FragCoord 벡터의 z값에는 해당 프래그먼트의 깊이 값이 포함되어 있다는 것을 알고 있습니다. 이 프래그먼트의 깊이 값을 색상으로 출력하면 장면 내 모든 프래그먼트의 깊이 값을 표시할 수 있습니다.

void main()
{             
    FragColor = vec4(vec3(gl_FragCoord.z), 1.0);
}  

프로그램을 실행해 보면 모든 것이 하얗게 나타나서 모든 깊이 값이 최대값인 1.0인 것처럼 보일 겁니다. 그렇다면 왜 어떤 깊이 값도 0.0에 가까워서 더 어둡게 나타나지 않는 걸까요?

이전 섹션에서 우리는 화면 공간에서의 깊이 값이 비선형적이라는 것을 설명했습니다. 즉, z 값이 작을 때는 정밀도가 매우 높고 z 값이 클 때는 정밀도가 낮습니다. 프래그먼트의 깊이 값은 거리에 따라 빠르게 증가하므로 거의 모든 정점의 값이 1.0에 가깝습니다. 만약 우리가 객체에 아주 가까이 다가가면 결국 색상이 어두워지고 z 값이 작아지는 것을 볼 수 있을 것입니다.

이는 깊이 값의 비선형성을 명확하게 보여줍니다. 가까이 있는 물체는 멀리 있는 물체보다 깊이 값에 훨씬 더 큰 영향을 미칩니다. 단 몇 센티미터만 움직여도 색상이 어두운 색에서 완전히 흰색으로 바뀔 수 있습니다.

하지만 비선형 깊이 값을 선형 값으로 되돌릴 수 있습니다. 이를 위해서는 깊이 값에 대해서만 투영 과정을 역으로 수행해야 합니다. 즉, 먼저 깊이 값을 [0,1] 범위에서 정규화된 장치 좌표인 [-1,1] 범위로 다시 변환해야 합니다. 그런 다음 투영 행렬에서와 마찬가지로 비선형 방정식(방정식 2)을 역으로 적용하고, 이 역방정식을 결과 깊이 값에 적용합니다. 그러면 선형 깊이 값을 얻을 수 있습니다.

먼저 깊이 값을 NDC 형식으로 변환하는데, 이는 그다지 어렵지 않습니다.

float ndc = depth * 2.0 - 1.0; 

다음으로 얻어진 ndc 값을 이용하여 역변환을 적용함으로써 선형 깊이 값을 복원합니다.

float linearDepth = (2.0 * near * far) / (far + near - ndc * (far - near));

이 방정식은 깊이 값을 비선형화하기 위한 투영 행렬에서 유도되었으며, 근거리와 원거리 사이의 깊이 값을 반환합니다. 수학적으로 복잡한 이 글에서는 관심 있는 독자를 위해 투영 행렬에 대해 매우 자세하게 설명하고 있으며, 방정식이 어디에서 도출되었는지도 보여줍니다.

화면 공간의 비선형 깊이 값을 선형 깊이 값으로 변환하는 전체 프래그먼트 셰이더는 다음과 같습니다.

#version 330 core
out vec4 FragColor;

float near = 0.1; 
float far  = 100.0; 

float LinearizeDepth(float depth) 
{
    float z = depth * 2.0 - 1.0; // back to NDC 
    return (2.0 * near * far) / (far + near - z * (far - near));    
}

void main()
{             
    float depth = LinearizeDepth(gl_FragCoord.z) / far; // divide by far for demonstration
    FragColor = vec4(vec3(depth), 1.0);
}

선형화된 깊이 값은 근거리에서 원거리까지의 범위를 가지므로 대부분의 값이 1.0보다 커서 완전히 흰색으로 표시됩니다. 메인 함수에서 선형 깊이 값을 원거리로 나누면 선형 깊이 값이 [0, 1] 범위로 변환됩니다. 이렇게 하면 프래그먼트가 투영 절두체의 원거리 평면에 가까워질수록 장면이 점차 밝아지는 것을 볼 수 있어 시각화에 더 효과적입니다.

이제 애플리케이션을 실행하면 거리에 비례하는 깊이 값을 얻을 수 있습니다. 장면을 움직여 보면 깊이 값이 선형적으로 변하는 것을 확인할 수 있습니다.

색상이 대부분 검은색인 이유는 깊이 값이 근거리 평면(0.1)에서 원거리 평면(100)까지 선형적으로 변화하기 때문입니다. 원거리 평면은 우리로부터 여전히 상당히 멀리 떨어져 있습니다. 결과적으로 우리는 근거리 평면에 상대적으로 가까이 있기 때문에 더 낮은(더 어두운) 깊이 값을 얻게 됩니다.

Z-파이팅 (깊이 충돌)

두 평면이나 삼각형이 너무 가깝게 정렬되어 깊이 버퍼가 어느 도형이 앞에 있는지 정확하게 판단할 수 없을 때 흔히 시각적 오류가 발생할 수 있습니다. 그 결과 두 도형의 순서가 계속해서 바뀌는 것처럼 보여 이상하고 왜곡된 패턴이 나타납니다. 이러한 현상을 'z-파이팅'이라고 하는데, 마치 도형들이 누가 위에 있을지 다투는 것처럼 보이기 때문입니다.

지금까지 사용해 온 장면에서 z-파이팅 현상이 발생하는 부분이 몇 군데 있습니다. 컨테이너가 바닥과 정확히 같은 높이에 배치되어 있어 컨테이너의 바닥면이 바닥면과 동일 평면에 있습니다. 따라서 두 평면의 깊이 값이 같아지므로, 결과적으로 깊이 테스트에서 어느 평면이 정확한지 판단할 수 없게 됩니다.

컨테이너 내부로 카메라를 이동시키면 그 효과가 확연히 드러납니다. 컨테이너 바닥 부분이 컨테이너 평면과 바닥 평면 사이를 지그재그 패턴으로 끊임없이 오가는 것을 볼 수 있습니다.

Z-파이팅은 깊이 버퍼에서 흔히 발생하는 문제이며, 일반적으로 객체가 멀리 있을수록 더 두드러집니다(깊이 버퍼의 z값이 커질수록 정밀도가 떨어지기 때문입니다). Z-파이팅을 완전히 방지할 수는 없지만, 몇 가지 요령을 사용하면 장면에서 Z-파이팅을 완화하거나 완전히 방지할 수 있습니다.

z-파이팅 방지하기

첫 번째이자 가장 중요한 요령은 객체들을 서로 너무 가깝게 배치하여 삼각형들이 겹치지 않도록 하는 것입니다. 두 객체 사이에 작은 오프셋을 주면 두 객체 간의 Z-파이팅 현상을 완전히 없앨 수 있습니다. 컨테이너와 평면의 경우, 컨테이너를 양의 y 방향으로 약간 위쪽으로 이동시키는 것만으로도 충분했습니다. 컨테이너 위치의 작은 변화는 거의 눈에 띄지 않으면서 Z-파이팅을 완전히 줄일 수 있었습니다. 하지만 이 방법은 각 객체를 수동으로 조정하고 장면 내 어떤 객체도 Z-파이팅을 발생시키지 않는지 철저히 테스트해야 합니다.

두 번째 요령은 근접 평면을 최대한 멀리 설정하는 것입니다. 이전 섹션에서 설명했듯이 근접 평면에 가까울수록 정밀도가 매우 높아지므로 근접 평면을 뷰어에서 멀리 이동시키면 전체 절두체 범위에서 정밀도가 크게 향상됩니다. 그러나 근접 평면을 너무 멀리 설정하면 가까운 객체가 잘릴 수 있으므로 일반적으로 장면에 가장 적합한 근접 거리를 찾기 위해 조정과 실험을 거쳐야 합니다.

성능 저하를 감수해야 하지만, 또 다른 유용한 방법은 더 높은 정밀도의 깊이 버퍼를 사용하는 것입니다. 대부분의 깊이 버퍼는 24비트 정밀도를 가지지만, 요즘 GPU는 대부분 32비트 깊이 버퍼를 지원하므로 정밀도가 상당히 높아집니다. 따라서 성능 저하를 감수하더라도 깊이 테스트에서 훨씬 더 높은 정밀도를 얻을 수 있어 Z-파이팅 현상을 줄일 수 있습니다.

지금까지 설명한 세 가지 기술은 가장 흔하고 쉽게 적용할 수 있는 Z-파이팅 방지 기술입니다. 물론 더 많은 노력이 필요하지만 Z-파이팅을 완전히 차단하지 못하는 다른 기술들도 있습니다. Z-파이팅은 흔히 발생하는 문제이지만, 위에 나열된 기술들을 적절히 조합해서 사용하면 Z-파이팅 문제를 크게 줄일 수 있을 것입니다.

Comments