TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

[번역][NVIDIA] Depth Precision Visualized

jplee 2026. 5. 25. 16:45

Depth precision은 그래픽스 프로그래머라면 언젠가는 반드시 씨름하게 되는 골칫거리입니다. 이 주제에 대해서는 이미 많은 글과 논문이 쓰였고, 게임·엔진·디바이스마다 서로 다른 depth buffer 포맷과 설정이 사용되고 있습니다.

Perspective projection과 상호작용하는 방식 때문에, GPU 하드웨어의 depth mapping은 조금 난해합니다. 방정식을 들여다본다고 해서 바로 감이 오는 것도 아닙니다. 어떻게 동작하는지 직관을 얻으려면, 그림을 좀 그려 보는 편이 도움이 됩니다.

이 글은 크게 세 부분으로 구성되어 있습니다. 첫 번째 부분에서는 nonlinear depth mapping이 왜 필요한지에 대한 동기를 설명하려고 합니다. 두 번째 부분에서는 여러 상황에서 nonlinear depth mapping이 어떻게 동작하는지 직관적이고 시각적으로 이해할 수 있도록 몇 가지 다이어그램을 보여 줍니다. 세 번째 부분에서는 Paul Upchurch와 Mathieu Desbrun의 2012년 논문 Tightening the Precision of Perspective Rendering의 주요 결과를 논의하고 재현합니다. 이 논문은 floating-point roundoff error가 depth precision에 미치는 영향을 다룹니다.

왜 1/z인가

GPU 하드웨어 depth buffer는 처음 접했을 때 순진하게 기대할 법한 것과 달리, 카메라 앞에 있는 물체까지의 거리를 선형으로 저장하지 않습니다. 대신 depth buffer는 world-space depth의 역수에 비례하는 값을 저장합니다. 저는 이 관례가 왜 생겼는지 간단히 설명해 보려고 합니다.

이 글에서는 depth buffer에 저장되는 값, 즉 [0, 1] 범위의 값을 d로 표기하고, world-space depth, 다시 말해 meter 같은 월드 단위에서 view axis를 따라 잰 거리를 z로 표기하겠습니다. 일반적으로 둘 사이의 관계는 다음과 같은 형태입니다.

여기서 a, b는 near plane과 far plane 설정에 관련된 상수입니다. 다시 말해 d는 언제나 1/z를 어떤 식으로든 선형 remapping한 값입니다.

겉으로만 보면 dz의 어떤 함수로 잡아도 될 것처럼 생각할 수 있습니다. 그런데 왜 하필 이런 선택을 할까요? 여기에는 크게 두 가지 이유가 있습니다.

첫째, 1/z는 perspective projection의 틀 안에 자연스럽게 들어맞습니다. Perspective projection은 직선을 직선으로 보존한다고 보장되는 가장 일반적인 변환 계열입니다. 이 성질은 하드웨어 rasterization에 편리합니다. 삼각형의 직선 edge가 screen space에서도 계속 직선으로 남기 때문입니다. 하드웨어가 이미 수행하는 perspective divide를 이용하면 1/z의 선형 remapping을 만들 수 있습니다.

물론 이 접근의 진짜 강점은 projection matrix를 다른 matrix와 곱할 수 있다는 점입니다. 덕분에 여러 변환 단계를 하나로 합칠 수 있습니다.

두 번째 이유는 Emil Persson이 지적했듯이, 1/z가 screen space에서 선형이라는 점입니다. 그래서 rasterizing하는 동안 삼각형 위에서 d를 보간하기 쉽고, hierarchical Z-buffer, early Z-culling, depth buffer compression 같은 작업도 훨씬 쉬워집니다.

Depth Map을 그래프로 보기

방정식은 어렵습니다. 그림을 봅시다!

이 그래프는 왼쪽에서 오른쪽으로 읽고, 그다음 아래쪽으로 내려가면 됩니다. 왼쪽 축에 그려진 d에서 시작합니다. d1/z의 임의의 선형 remapping일 수 있으므로, 이 축 위에서 0과 1을 원하는 위치에 둘 수 있습니다. 눈금은 서로 다른 depth buffer 값을 나타냅니다. 설명을 위해 여기서는 4-bit normalized integer depth buffer를 시뮬레이션하고 있습니다. 그래서 균일한 간격의 눈금이 16개 있습니다.

눈금을 수평으로 따라가서 1/z 곡선과 만나는 지점을 찾고, 거기서 아래쪽 축으로 내려가면 됩니다. 바로 그 위치가 world-space depth 범위에서 각각의 개별 값이 놓이는 곳입니다.

위 그래프는 D3D와 비슷한 API에서 사용하는 “표준” depth mapping을 보여 줍니다. 1/z 곡선 때문에 값들이 near plane 근처에 빽빽하게 몰리고, far plane 근처의 값들은 꽤 넓게 퍼지는 모습을 바로 볼 수 있습니다.

Near plane이 depth precision에 왜 그렇게 큰 영향을 미치는지도 쉽게 알 수 있습니다. Near plane을 더 가까이 당기면 d 범위가 1/z 곡선의 asymptote 쪽으로 치솟게 되고, 값의 분포는 훨씬 더 한쪽으로 치우치게 됩니다.

마찬가지로 이 맥락에서는 far plane을 무한대로 밀어내도 영향이 그리 크지 않은 이유도 쉽게 보입니다. 그저 d 범위를 1/z = 0 쪽으로 약간 더 확장하는 것일 뿐입니다.

그렇다면 floating-point depth는 어떨까요? 다음 그래프는 exponent bit 3개와 mantissa bit 3개를 가진 가상의 float format에 해당하는 눈금을 추가한 것입니다.

이제 [0, 1] 범위 안에 40개의 서로 다른 값이 생겼습니다. 앞서의 16개 값보다 꽤 많아졌지만, 대부분은 실제로 더 많은 precision이 별로 필요하지 않은 near plane 근처에 쓸모없이 몰려 있습니다.

이제는 널리 알려진 트릭이 하나 있습니다. Depth range를 뒤집어서 near plane을 d = 1에, far plane을 d = 0에 매핑하는 것입니다.

훨씬 낫습니다! 이제 floating-point의 quasi-logarithmic distribution이 1/z의 nonlinearity를 어느 정도 상쇄합니다. 그 결과 near plane에서는 integer depth buffer와 비슷한 precision을 얻고, 그 외의 모든 영역에서는 precision이 크게 개선됩니다. 멀리 갈수록 precision은 아주 천천히만 나빠집니다.

Reversed-Z 트릭은 아마 여러 번 독립적으로 재발견되었을 것입니다. 하지만 적어도 Eugene Lapidous와 Guofang Jiao의 SIGGRAPH ’99 논문까지는 거슬러 올라갑니다. 아쉽게도 이 논문은 공개 접근 링크가 없습니다. 이후에는 Matt PettineoBrano Kemen의 블로그 글, 그리고 Emil Persson의 SIGGRAPH 2012 발표 Creating Vast Game Worlds를 통해 다시 널리 알려졌습니다.

앞의 모든 다이어그램은 post-projection depth range가 [0, 1]이라고 가정했습니다. 이는 D3D의 관례입니다. 그렇다면 OpenGL은 어떨까요?

OpenGL은 기본적으로 post-projection depth range를 [-1, 1]로 가정합니다. Integer format에서는 차이가 없지만, floating-point에서는 모든 precision이 쓸모없이 가운데에 박혀 버립니다. 나중에 이 값이 depth buffer에 저장될 때는 [0, 1]로 다시 매핑되지만, 그건 도움이 되지 않습니다. 처음에 [-1, 1]로 매핑되는 과정에서 이미 범위의 far half 쪽 precision이 모두 망가졌기 때문입니다. 그리고 대칭성 때문에 reversed-Z 트릭도 여기서는 아무 효과가 없습니다.

다행히 desktop OpenGL에서는 널리 지원되는 ARB_clip_control extension으로 이 문제를 고칠 수 있습니다. 이 기능은 이제 OpenGL 4.5에서 glClipControl로 core에 들어와 있습니다. 안타깝게도 GL ES에서는 방법이 없습니다.

Roundoff Error의 영향

1/z mapping과 float depth buffer를 쓸지 integer depth buffer를 쓸지의 선택은 depth precision 이야기에서 큰 부분을 차지합니다. 하지만 전부는 아닙니다. 렌더링하려는 scene을 표현하기에 충분한 depth precision을 갖고 있더라도, vertex transformation 과정의 산술 오차 때문에 precision이 좌우되는 상황은 쉽게 발생합니다.

앞서 언급했듯이 Upchurch와 Desbrun은 이 문제를 연구했고, roundoff error를 최소화하기 위해 두 가지 주요 권장 사항을 제시했습니다.

  1. 무한 far plane을 사용한다.
  2. Projection matrix를 다른 matrix와 분리해 두고, view matrix에 합성하지 말고 vertex shader에서 별도의 연산으로 적용한다.

Upchurch와 Desbrun은 분석적인 기법을 통해 이 권장 사항을 도출했습니다. 각 산술 연산에서 roundoff error를 작은 random perturbation으로 보고, transformation 과정 전체에서 이를 1차 항까지 추적하는 방식입니다. 저는 이 결과를 직접 시뮬레이션으로 확인해 보기로 했습니다.

source code는 여기에 있습니다. Python 3.4와 numpy를 사용했습니다. 동작 방식은 이렇습니다. 먼저 depth 순서대로 정렬된 random point sequence를 생성합니다. 이 점들은 near plane과 far plane 사이에 선형 또는 로그 간격으로 배치됩니다. 그런 다음 32-bit float precision을 사용해 view matrix, projection matrix, perspective divide를 차례로 통과시킵니다. 선택적으로 최종 결과를 24-bit integer로 quantize하기도 합니다. 마지막으로 sequence를 훑으면서, 원래는 서로 다른 depth를 갖던 인접한 두 점이 같은 depth value로 매핑되어 구분 불가능해졌는지, 또는 실제로 순서가 뒤바뀌었는지를 셉니다. 다시 말해 서로 다른 시나리오에서 depth comparison error가 얼마나 자주 발생하는지 측정하는 것입니다. 이는 Z-fighting 같은 문제와 대응됩니다.

다음은 near = 0.1, far = 10K, 그리고 선형 간격의 depth 10K개를 사용해 얻은 결과입니다. 로그 depth spacing과 다른 near/far 비율도 시도해 보았는데, 세부 수치는 달라졌지만 결과의 전반적인 경향은 같았습니다.

표에서 “indist”는 indistinguishable, 즉 가까운 두 depth가 같은 최종 depth buffer value로 매핑되어 구분할 수 없게 된 경우를 뜻합니다. “swap”은 가까운 두 depth의 순서가 서로 뒤바뀐 경우를 뜻합니다.

이 결과를 그래프로 그리지 않은 점은 양해해 주세요. 차원이 너무 많아서 보기 좋게 그래프로 만들기가 쉽지 않습니다! 어쨌든 숫자를 보면 몇 가지 일반적인 결과는 분명합니다.

  • 대부분의 setup에서는 float depth buffer와 integer depth buffer 사이에 차이가 없습니다. Arithmetic error가 quantization error를 압도합니다. 그 이유 중 하나는 float32와 int24가 [0.5, 1] 범위에서 거의 같은 크기의 ulp를 갖기 때문입니다. float32는 23-bit mantissa를 갖고 있으므로, depth range의 대부분에서는 실제로 추가적인 quantization error가 거의 없습니다.
  • 많은 경우 view matrix와 projection matrix를 분리하는 것, 즉 Upchurch와 Desbrun의 권장 사항을 따르는 것은 어느 정도 개선을 만들어 냅니다. 전체 error rate를 낮추지는 못하지만, swap을 indistinguishable로 바꾸는 경향이 있어 보입니다. 이는 그래도 올바른 방향의 개선입니다.
  • 무한 far plane은 error rate에 아주 미세한 차이만 만듭니다. Upchurch와 Desbrun은 절대적인 numerical error가 25% 줄어든다고 예측했지만, 그것이 comparison error rate 감소로 이어지는 것 같지는 않습니다.

하지만 위의 포인트들은 실전적으로는 거의 부차적인 이야기입니다. 여기서 정말 중요한 결과는 이것입니다. reversed-Z mapping은 사실상 마법입니다. 한번 보세요.

  • Float depth buffer와 함께 reversed-Z를 사용하면 이 테스트에서는 error rate가 0이 됩니다. 물론 입력 depth value의 간격을 계속 좁히면 언젠가는 error를 만들 수 있습니다. 그래도 reversed-Z + float은 다른 어떤 선택지보다 터무니없이 정확합니다.
  • Integer depth buffer와 함께 reversed-Z를 사용하는 경우에도, 다른 integer 선택지들과 동등한 수준입니다.
  • Reversed-Z는 precomposed view/projection matrix와 separate view/projection matrix의 차이, 그리고 finite far plane과 infinite far plane의 차이를 지워 버립니다. 다시 말해 reversed-Z를 사용하면 projection matrix를 다른 matrix와 합성해도 되고, 원하는 far plane을 써도 precision에는 전혀 영향을 주지 않습니다.

여기서 결론은 분명하다고 생각합니다. Perspective projection을 사용하는 상황이라면 그냥 floating-point depth buffer와 reversed-Z를 쓰세요! 그리고 floating-point depth buffer를 사용할 수 없더라도 reversed-Z는 여전히 사용하는 편이 좋습니다. 물론 이것이 모든 precision 문제를 해결하는 만병통치약은 아닙니다. 특히 극단적인 depth range를 포함하는 open-world 환경을 만들고 있다면 더 그렇습니다. 하지만 훌륭한 출발점인 것은 분명합니다.

Nathan은 그래픽스 프로그래머이며, 현재 NVIDIA의 DevTech software team에서 일하고 있습니다. 더 많은 글은 그의 블로그 여기에서 읽을 수 있습니다.

원문

Depth Precision Visualized | NVIDIA Developer

 

Depth Precision Visualized

Depth precision is a pain point that every graphics programmer has to struggle with sooner or later. Many articles and papers have been written on the topic, and a variety of different depth buffer formats and setups are found across different games, engin

developer.nvidia.com