https://therealmjp.github.io/posts/to-earlyz-or-not-to-earlyz/
To Early-Z, or Not To Early-Z
Depth In The Logical Rendering Pipeline Where Does Early-Z Fit In? When Does Early-Z Have To Be Disabled? Discard/Alpha Test Pixel Shader Depth Export UAVs/Storage Textures/Storage Buffers Forcing Early-Z Forced Early-Z With UAVs And Depth Writes Rasterize
therealmjp.github.io
마이크로소프트 코파일럿을 사용하여 번역하였습니다.
To Early-Z, or Not To Early-Z

- Depth In The Logical Rendering Pipeline
- Where Does Early-Z Fit In?
- When Does Early-Z Have To Be Disabled?
- Forcing Early-Z
- Summary and Conclusion
우리가 GPU에서 당연하게 여기는 것 중 하나는 Early-Z 테스트입니다.
이 기능은 Z prepass가 존재하는 주된 이유이며, Forward Rendering이 Pixel Shader Overdraw에 완전히 압도되지 않고 여전히 유효한 렌더링 방식으로 남아 있을 수 있게 해주는 핵심 요소 중 하나입니다.
(물론, 대신 Quad Overshading에 압도되긴 하지만, 그건 넘어가죠.)
이처럼 널리 사용되고 수십 년간 활용되어 왔음에도 불구하고, Early-Z는 여전히 혼란스럽고 자주 오해받는 개념입니다.
그 이유는 여러 가지가 있지만, 개인적으로는 Early-Z가 마치 '마법 같은' 최적화로 작동하며, 프로그래머가 직접 눈으로 확인하거나 인지하기 어렵기 때문이라고 생각합니다. (물론 성능을 제외하면 말이죠.)
이처럼 명시적이지 않은 특성 때문에, Early-Z가 실제로 작동하고 있는지는 성능 지표나 Pixel Shader 실행 통계를 보지 않으면 알기 어렵습니다.
이 글에서는 이러한 점을 고려하여,
- Early-Z란 무엇인지
- 어떻게 작동하는지
- 언제 작동하지 않는지를 설명하고, 마지막에는 유용한 정보를 요약한 표도 제공할 예정입니다.
설명 중에는 제가 만든 DX12 Early-Z Tester에서 얻은 인사이트와 스크린샷도 포함할 것입니다.
이 테스트 앱은 아래에서 설명할 다양한 시나리오를 생성할 수 있으며, Pixel Shader 호출 횟수를 통해 Early-Z Culling이 발생했는지 여부를 확인할 수 있습니다.
마지막으로 이 글은 데스크탑급 그래픽 하드웨어를 중심으로 작성되었음을 미리 알려드립니다.
여기서 다루는 많은 내용이 모바일 GPU에도 적용될 수 있지만, 저는 모바일 GPU에 대해 충분히 익숙하지 않기 때문에 데스크탑과의 차이점을 자신 있게 설명할 수는 없습니다.
Depth In The Logical Rendering Pipeline 논리적 렌더링 파이프라인에서의 깊이 처리
D3D, Vulkan, GL과 같은 그래픽 API는 렌더링을 위한 **논리적 파이프라인(logical pipeline)**을 기준으로 정의됩니다.
이 파이프라인은 의도적으로 추상화되어 있으며, 특정 하드웨어 구현에 엄격하게 묶여 있지 않습니다.
대신, 프로그래머가 삼각형을 그릴 때 기대할 수 있는 동작을 정의합니다.
이러한 구조 덕분에 **IHV(Independent Hardware Vendor)**와 드라이버는 하드웨어를 다양한 방식으로 구성할 수 있는 자유를 가지며, 세대가 바뀔 때마다 구현을 변경할 수도 있습니다.
예를 들어, D3D의 논리적 파이프라인은 다음과 같은 구조를 가집니다:
https://learn.microsoft.com/en-us/windows/win32/direct3d11/overviews-direct3d-11-graphics-pipeline

이 파이프라인은 D3D12에서도 (Mesh Shader가 추가된 것을 제외하면) 근본적으로는 변하지 않았으며, GL이나 Vulkan에서도 매우 유사한 구조를 가지고 있습니다. 단지 용어만 다를 뿐입니다.
모든 경우에 있어서, Depth Buffer 연산은 논리적 파이프라인의 가장 마지막, 즉 Pixel/Fragment Shader가 실행된 이후에 수행되는 것으로 정의되어 있습니다.
D3D에서는 이 단계를 **Output-Merger (OM)**라고 부르며, 이 단계는 렌더 타겟에 대한 쓰기/블렌딩이 발생하고, Depth/Stencil 연산도 논리적으로 이곳에서 수행됩니다.
현대적인 관점에서 보면 Depth 연산이 마지막에 수행된다는 것이 이상하게 느껴질 수 있지만, 역사적인 맥락을 적용해 보면 이는 매우 자연스러운 구조입니다:
- Depth/Stencil 연산의 주된 목적은 렌더 타겟에 실제로 쓰기를 할지 말지를 결정하는 것이므로,쓰기/블렌딩이 발생하는 논리적 단계와 함께 있는 것이 타당합니다.
- Pixel Shader는 discard나 alpha test와 같은 연산을 수행할 수 있으며,이는 Depth 연산에도 영향을 줄 수 있습니다.
- 초기의 GPU는 하드웨어 기반 Depth Buffer를 갖추고 있었지만,이는 최적화 목적이 아니라 가시성 판별 알고리즘으로서 렌더링의 마지막 단계에서 수행되었습니다.
여기서 중요한 점은, 이러한 “논리적” 구조는 GPU가 실제로 어떤 방식으로 렌더링을 수행했는지와 관계없이,결과적으로 우리가 보게 되는 출력과 일치한다는 것입니다.
GPU는 내부적으로 Binning 단계를 명시적으로 가질 수도 있고,
- *VS(Vertex Shader) + HS(Hull Shader) + DS(Domain Shader)**를 하나의 하드웨어 셰이더 스테이지로 통합했을 수도 있습니다.
하지만 어쨌든 Depth Test는 우리가 기대하는 대로 렌더 타겟과 Depth Buffer 값을 생성합니다.
이러한 논리적 파이프라인의 관점에서 보면, Pixel/Fragment Shader에서 UAVs(또는 Storage Buffer, Storage Texture)에 쓰기를 하더라도기본적으로 Depth Test의 영향을 받지 않는 이유도 명확해집니다.결국 Depth 연산은 Pixel Shader 이후에 수행되기 때문이며,그렇기 때문에 영향을 받지 않는 것이 당연합니다.
물론 실제로는 UAV 관련 동작은 훨씬 더 복잡하지만, 이에 대해서는 나중에 더 자세히 다루겠습니다.
Where Does Early-Z Fit In? Early-Z는 어디에 적합할까요?
자, 지금 여러분은 Early-Z에 관한 글을 읽고 있으니, 논리적 파이프라인만으로는 모든 것을 설명할 수 없다는 것은 이미 눈치채셨을 겁니다.
그렇다면 질문이 생기죠:
논리적 파이프라인에서는 Depth 연산이 가장 마지막에 수행된다고 되어 있는데,그렇다면 Early-Z는 어떻게 작동하는 걸까요?
그 해답은 다음과 같습니다:
드라이버는 Pixel Shader가 실제로 실행되기 전에 Depth 연산을 "몰래" 앞당겨 수행할 수 있는 충분한 재량을 가지고 있습니다.
이렇게 하면 **Pixel Shader 자체를 아예 실행하지 않고도 픽셀을 제거(cull)**할 수 있게 되는 것이죠.

기본적으로 **드라이버는 현재의 Pixel Shader와 렌더 상태(render states)**를 분석한 후, Pixel Shader 실행을 생략(cull)해도 논리적 파이프라인이 보장하는 결과와 동일한 출력이 나올 수 있는지 판단합니다.
예를 들어, 렌더 타겟에만 쓰기를 수행하는 일반적인 불투명(opaque) 렌더링이라면 이 판단은 매우 간단합니다.
Pixel Shader를 생략하더라도 최종 출력은 동일하기 때문입니다.
즉, 렌더 타겟에 쓰기를 생략하는 것과 Pixel Shader 자체를 실행하지 않는 것 사이에는 시각적인 차이가 없습니다.
이 방식은 Depth Write가 활성화되어 있어도 작동합니다.
Early-Z 연산은 기존 Z Buffer 값을 먼저 테스트하고, 테스트를 통과하면 Z 값을 기록합니다.
이와 동시에 Pixel Shader도 실행될 수 있습니다.
이후에 **다른 픽셀(Fragment)**이 더 가까운 Depth 값을 가지고 있어서 Depth Test를 통과하더라도, 렌더 타겟의 결과는 단순히 덮어쓰기(overwrite) 되므로 문제가 없습니다.
이런 관점에서 보면, **삼각형을 그리는 순서(order)**는 중요하지 않습니다.
결국 결과는 동일하게 나오기 때문입니다.
물론 성능 측면에서는 순서가 중요합니다.
Depth Write가 활성화된 상태에서는 가까운 삼각형부터 먼 삼각형 순으로 그리는 것이 이상적입니다.
이렇게 하면 결국 기여하지 않는 Pixel Shader 스레드의 낭비를 줄일 수 있기 때문입니다.
또는, Depth-Only Prepass를 사용하는 일반적인 방법도 있습니다.
이 방식은 Depth Buffer를 미리 가장 가까운 Depth 값으로 채워두기 때문에, Pixel Shader는 대부분 실제로 보이는 픽셀에 대해서만 실행됩니다.
어느 쪽이든, 하드웨어와 드라이버가 이런 최적화를 수행하면서도 논리적 파이프라인을 준수한다는 점은 매우 영리한 설계입니다.
이제 테스트 앱에서 간단한 테스트 케이스를 실행해 보겠습니다. 이 글에서 소개하는 테스트들은 모두 AMD RX 7900 XT에서 실행됩니다.
먼저, 카메라에서 먼 빨간 삼각형을 먼저 그리고, 그 앞에 가까운 삼각형을 그리는 Back-to-Front 순서로 두 개의 삼각형을 그려보겠습니다.

이 경우, 두 개의 삼각형을 그렸을 때 Pixel Shader 호출 횟수가 648,000번에 달하는 것을 확인할 수 있습니다.
이제 그리기 순서를 반대로 바꿔서, 더 최적화된 Front-to-Back 순서로 그려보겠습니다:

이번에는 Pixel Shader가 실행된 픽셀 수가 440,640개로 줄어들었습니다.
앞선 648,000개에 비해 상당히 적은 수치이며, 이는 Early-Z가 제대로 작동하고 있다는 증거입니다.
하지만 한 가지 기억해야 할 점은, Early-Z가 “안전하다”고 판단되는 경우에도 실제로 그것을 적용할지는드라이버와 하드웨어의 판단에 달려 있다는 것입니다.(물론 강제로 적용하는 방법도 있지만, 그건 나중에 설명하겠습니다.)
- *드라이버는 휴리스틱(heuristics)**에 따라 Early-Z를 적용하지 않을 수도 있습니다.
왜냐하면 하드웨어가 Early-Z Culling을 수행하기 위해 추가적인 작업을 해야 할 수도 있기 때문입니다.
또한, **Early-Z에서 픽셀이 제거되는 단위(granularity)**는 사용 중인 하드웨어에 따라 달라질 수 있습니다.
초기의 PC 하드웨어에서 Early-Z는 **Hierarchical Depth Buffer (Hi-Z)**를 통해 구현되었습니다.
이 방식은 Depth Buffer의 NxN 영역에 대해 최소/최대 Depth 값을 저장하는 별도의 버퍼를 유지합니다.
이러한 구조는 전체 픽셀 단위의 Depth 값을 읽지 않고도 빠르고 거친 Z Culling을 가능하게 해줍니다.
D3D10 시대에 들어서면서 ATI/AMD와 Nvidia 모두 정밀한 Early-Z 테스트를 수행할 수 있게 되었지만, 여전히 **Hierarchical Depth Buffer는 coarse culling(거친 제거)**을 위해 사용됩니다.
이는 더 비용이 큰 정밀 테스트를 수행하기 전에 불필요한 픽셀을 미리 제거하는 데 유용합니다.
📚 참고: Hierarchical Z 설명 (Fabian Giesen 블로그)
When Does Early-Z Have To Be Disabled?
Early-Z가 반드시 비활성화되어야 하는 경우는 언제일까?
앞서 언급한 표준적인 불투명(opaque) 렌더링의 경우, *Early-Z 테스트에 실패하면 Pixel Shader를 생략(cull)하는 것이 “안전하다”**고 설명했습니다.
하지만 분명히, 논리적 파이프라인의 규칙을 위반하게 되는 경우에는 드라이버가 Early-Z를 사용할 수 없는 상황도 존재합니다.
이제부터는 그런 대표적인 예시들을 살펴보겠습니다.
Discard/Alpha Test
이 경우는 아마도 가장 흔하게 마주치는 상황이며, “Early-Z를 깨뜨리는 요소들”이라는 맥락에서 가장 자주 언급되는 사례이기도 합니다.
간단히 요약하자면, Pixel Shader가 discard 연산을 실행하거나, clip()과 같은 내장 함수를 통해 간접적으로 discard를 사용하는 경우,
해당 픽셀에 대해서는 Depth, Stencil, Render Target 쓰기가 모두 건너뛰어집니다.
이러한 동작은 특히 Depth Write가 활성화된 상태에서는 Early-Z에 직접적인 영향을 미치게 됩니다,
왜냐하면 Pixel Shader가 끝날 때까지는 Depth 값을 실제로 기록할 수 없기 때문입니다.
처음에는, Pixel Shader에서 discard를 사용하고 Depth Write도 활성화된 경우, 드라이버는 어쩔 수 없이 Late-Z로 되돌아가야 할 것처럼 보일 수 있습니다.
물론 실제로 그렇게 동작할 수도 있지만, 많은 GPU는 여전히 어느 정도의 Early Depth Test를 수행할 수 있습니다.
즉, **Depth Write는 나중으로 미루더라도 Pixel Shader 실행 자체는 미리 제거(cull)**할 수 있다는 뜻입니다.
GPU가 이렇게 할 수 있는 이유는, discard가 삼각형의 정점 위치를 보간하여 생성된 픽셀의 Z/Depth 값을 변경하지 않기 때문입니다.
단지 그 Depth 값을 쓸지 말지를 조건부로 만드는 것뿐입니다.
따라서, 현재 Depth Buffer의 값에 기반한 Depth Test에서 실패한 픽셀에 대해Pixel Shader 스레드를 제거하는 것은 여전히 “안전”한 동작입니다.
이 내용은 GDC 2013의 오래된 슬라이드에서도 언급되어 있으며, 해당 슬라이드에서는 Pixel Shader 실행 이후에 Depth Write를 지연시키면서도Coarse Hierarchical Depth Test는 여전히 수행되는 구조를 보여줍니다.
https://www.slideshare.net/slideshow/dx11-performancereloaded/46398197

이처럼 Depth Test를 **“분리(split)”**해서 처리하면, discard가 포함된 상황에서도 Early-Z Culling이 가능해집니다.
하지만 Depth Write가 파이프라인의 더 뒤쪽에서 수행된다는 사실은 Early-Z Culling 비율에 부정적인 영향을 줄 수 있습니다.
이 현상을 실제로 확인해보기 위해, 앞서 살펴본 Front-to-Back 테스트 케이스로 다시 돌아가 보겠습니다.
단, 이번에는 Pixel Shader에 discard를 포함시키되, 실제로는 절대 실행되지 않는 조건으로 설정해보겠습니다:

이 시나리오에서는 Pixel Shader 호출 수가 약 582,000회로 측정되며, 프레임마다 약간의 변동이 있습니다.
이 수치는 discard가 없는 경우에 비해 Early-Z Culling 효율이 훨씬 낮은 것을 보여줍니다.
그 이유는, 더 멀리 있는 삼각형의 픽셀들이 Early-Z 테스트를 통과해버리는 경우가 많기 때문입니다.
이 시점에서는 더 가까운 삼각형이 아직 Z Buffer를 업데이트하지 않았기 때문이죠.
이 가설을 검증하기 위해, 두 번째 드로우 호출을 시작하기 전에 첫 번째 드로우가 완전히 끝나도록 글로벌 Barrier를 삽입해보겠습니다.

이러한 시나리오에서 글로벌 barrier를 삽입하면 (※ 실제로는 성능상 이유로 이렇게 하는 건 권장되지 않습니다),
Early-Z Culling 비율이 discard가 없던 경우와 훨씬 더 유사한 수준으로 향상됩니다.
또한 테스트 앱을 통해 확인해본 결과, Depth Buffer를 가장 가까운 값으로 초기화하면 Pixel Shader 호출이 전혀 발생하지 않음을 알 수 있습니다.
이로부터 우리는 다음과 같은 결론을 도출할 수 있습니다:
- Alpha Test를 사용하지 않는 불투명(opaque) 오브젝트들을 먼저 렌더링하는 것이 좋은 습관일 수 있다.
- 또는, 가려질 가능성이 높은 오브젝트들부터 먼저 렌더링하는 것도 효과적이다.
- discard를 Depth Prepass에서만 사용하고,그 이후 EQUAL Depth Test를 사용하는 본 렌더링 패스를 수행하는 방식도 무거운 Pixel Shader가 가려진 픽셀에 대해 실행되지 않도록 보장하는 데 효과적이다.
한편, Depth Write가 비활성화된 경우에는 Early-Z는 discard와 완전히 호환됩니다.
이 경우에는 Depth Buffer를 업데이트할 필요가 없기 때문에, Pixel Shader가 실행되기 전에 Depth Test를 수행하고 Culling하는 것이 완전히 안전합니다.
즉, **입자(Particles)**나 투명(Transparent) 렌더링에서 discard를 성능 최적화 용도로 자유롭게 사용할 수 있으며, Early-Z에 부정적인 영향을 줄 걱정은 없습니다.
여기서 매우 중요한 점은 다음과 같습니다:
컴파일된 셰이더 코드에 discard가 단 한 줄이라도 포함되어 있다면,그 자체만으로도 Early-Z에 영향을 줄 수 있다는 것!
실제로 어떤 픽셀도 discard되지 않더라도, 드라이버는 셰이더 실행 전에 Early-Z 적용 여부를 미리 결정해야 하기 때문에 discard가 존재한다는 사실만으로도 Early-Z 최적화가 제한될 수 있습니다.
즉, **런타임 상수나 uniform 값을 기반으로 discard를 피하려는 분기(branch)**가 실제로 실행되지 않더라도, Early-Z에는 여전히 영향을 줄 수 있습니다.
또한, Pixel Shader에서 SV_Coverage 또는 gl_SampleMask를 통해 커버리지를 export하는 것도 discard를 사용하는 것과 동일한 영향을 미치며,
Early-Z에 동일한 규칙이 적용됩니다.
Pixel Shader Depth Export
일반적으로 Depth Test에 사용되는 Depth 값은 *삼각형의 정점 Z 값을 보간(interpolate)**하여 생성됩니다.
하지만 Pixel/Fragment Shader는 이 Depth 값을 완전히 “재정의(override)”할 수 있는 기능도 가지고 있습니다.
- HLSL에서는 SV_Depth 속성을 사용하고,
- GLSL에서는 gl_FragDepth 전역 변수를 사용합니다.
이러한 Depth Export 기능은 유용한 도구가 될 수 있지만, Early-Z와는 자연스럽게 충돌하는 문제를 발생시킵니다.
미래를 예측할 수 없는 이상, 하드웨어는 Pixel Shader를 실행하지 않고서는 해당 픽셀의 Depth 값을 알 수 없습니다.
따라서, Pixel Shader가 Depth 값을 수동으로 export하는 경우, 하드웨어는 어쩔 수 없이 Late-Z로 되돌아갈 수밖에 없습니다.
중요한 점은, Depth Write가 활성화되어 있든 아니든 관계없이, 논리적 파이프라인을 존중하려면 Late-Z만이 유일하게 안전한 선택이라는 것입니다.
이 동작은 테스트 앱을 통해서도 확인할 수 있습니다:

Conservative Depth Export
이후 D3D11에서는 Conservative Depth Export라고 불리는 Depth Export의 변형들이 추가되었습니다.
이 기능들은 Pixel Shader가 출력하는 Depth 값에 대해 부등식 형태의 제약 조건을 추가할 수 있게 해줍니다.
예를 들어, SV_DepthGreaterEqual은 Pixel Shader가 출력하는 Depth 값이 삼각형 정점 보간으로 계산된 “기본(Natural)” Depth 값보다 크거나 같아야 한다는 제약을 의미합니다.
이러한 기능이 존재하는 이유는, 최종 Depth 값이 Pixel Shader 실행 전에는 알려지지 않더라도,이러한 제약 조건 덕분에 하드웨어가 일정 수준의 Early-Z 기능을 안전하게 활성화할 수 있기 때문입니다.
이를 위해서는, 이 부등식이 Depth Render State에서 사용되는 부등식과 “반대 방향”이어야 합니다.
예를 들어 SV_DepthGreaterEqual을 사용하는 상황에서,
- Depth Write는 비활성화,
- Depth Test는 LESS_EQUAL로 설정되어 있다고 가정해봅시다.
이 경우, 드라이버와 하드웨어는 다음과 같이 판단할 수 있습니다:
- Pixel Shader는 기본 Depth 값보다 더 큰 값만 출력할 수 있으므로,
- 기본 Depth 값이 Depth Test를 통과하지 못했다면,
- Pixel Shader가 어떤 값을 출력하든 해당 픽셀은 무조건 실패하게 됩니다.
따라서, Pixel Shader를 안전하게 Culling할 수 있으며, 출력 결과에는 아무런 영향을 주지 않습니다.
이 구조는 discard와 유사한 상황으로, Pixel Shader가 Early-Z 테스트 결과를 무효화할 수 없는 경우에는Early-Z를 안전하게 사용할 수 있다는 점에서 동일합니다.
하지만 discard와 마찬가지로, Conservative Depth Export와 Depth Write가 함께 사용되는 경우에는 상황이 더 복잡해집니다.
이 경우에는 최종 Depth 값이 Pixel Shader 실행 전에는 알려지지 않기 때문에, Depth Write를 미리 수행할 수 없습니다.
따라서 Depth Write는 반드시 Late-Z 단계로 지연되어야 합니다.
그럼에도 불구하고, 하드웨어가 지원한다면 discard의 경우처럼 Early-Z Culling은 여전히 가능합니다.
이러한 하드웨어에서는, Depth Write가 활성화되어 있더라도 Conservative Depth Export는임의의 Depth Export보다 Early-Z 측면에서 더 유리한 점을 유지합니다.물론, Early-Z Culling 비율이 줄어들 수 있다는 점은 discard와 동일한 주의사항입니다.
이 동작 역시 테스트 앱을 통해 확인할 수 있습니다.

"여기서는 매우 이른 시점에 전체 648,000개의 shader invocation이 발생하는데, 이는 우리가 discard를 사용했을 때보다도 실제로 더 나쁜 결과입니다. barrier를 강제로 적용하면 다시 한 번 거의 완벽한 culling 비율을 얻을 수 있습니다."

UAVs/Storage Textures/Storage Buffers
모두들 준비하세요. 이제부터 상황이 훨씬 더 복잡해집니다. 앞서 언급했듯이, 텍스처나 버퍼에 임의로 쓰기를 수행하는 pixel shader는 하드웨어가 Late-Z로 전환되도록 만듭니다. Early-Z를 활성화하는 드라이버가 전제로 삼는 것은 pixel shader가 명확한 출력과 부작용이 없는 '순수한(pure)' 함수라는 점입니다. 하지만 UAVs, storage buffers, storage textures에 대한 쓰기는 본질적으로 shader 실행의 부작용이기 때문에 이 전제를 위반하게 됩니다. 이런 shader를 마주한 드라이버는, depth test가 UAV에 기록되는 값에 영향을 주지 않도록 하기 위해 어쩔 수 없이 Late-Z를 사용하게 됩니다.
…단, Early-Z가 강제로 활성화되지 않는다면 말이죠. 이는 shader-writable 리소스가 API에 도입될 때 함께 추가된 기능입니다. 그러니 이제 그 이야기를 해보죠.
Forcing Early-Z
“UAV이 depth testing을 무시한다”는 동작은 논리적인 파이프라인 관점에서는 타당하지만, pixel shader에서 임의로 쓰기를 수행할 수 있는 다양한 작업에는 분명히 유용하지 않습니다. 예를 들어 2009년의 사례를 보면, Order Independent Transparency(OIT) 알고리즘에서는 투명한 프래그먼트를 연결 리스트에 추가해 나중에 정렬할 수 있도록 합니다. 하지만 불투명한 표면에 가려진 투명 프래그먼트를 추가하는 것은 의미가 없으며, shader 내부에서 수동으로 depth test를 수행하더라도 Early-Z culling이 제공하는 성능 이점을 완전히 얻을 수는 없습니다. Early-Z 하드웨어는 바로 거기 있는데, 그냥 사용하게 해줘야 하지 않을까요?
D3D11은 이 요구를 해결하기 위해 pixel shader의 엔트리 포인트에 [earlydepthstencil]이라는 새로운 속성을 지정할 수 있도록 했습니다. 이름은 다소 길지만, 이 속성은 말 그대로의 기능을 수행합니다: pixel shader가 실행되기 이전에 모든 depth 및 stencil 연산을 강제로 수행하게 하고, 테스트에 실패하면 해당 pixel shader는 아예 실행되지 않도록 합니다. 이는 앞서 설명한 OIT 사례에 딱 맞는 기능입니다. depth test의 결과와 최적화를 모두 얻으면서도, 여전히 UAV 관련 작업을 수행할 수 있습니다. 사실 이 속성은 UAV를 사용하는 pixel shader에만 국한되지 않고, 해당 동작을 강제로 적용하고 싶은 모든 pixel shader에 사용할 수 있습니다.
이 기능은 분명히 훌륭하고 유용하지만, 모든 shader에 무작정 이 속성을 붙이기 전에 depth 읽기 및 쓰기를 pixel shader 이전으로 완전히 옮긴다는 것이 의미하는 바를 고려해야 합니다. 예를 들어, 어떤 형태로든 depth 값을 출력하는 shader는 이 속성과 완전히 호환되지 않으며, 해당 shader의 depth 출력은 완전히 무시됩니다. 더 미묘한 문제로는, depth 쓰기가 활성화된 상태에서 discard를 사용할 경우 이 속성이 discard의 동작을 깨뜨릴 수 있습니다. discard는 여전히 render target 쓰기를 막지만, 그 시점에는 이미 depth 버퍼에 값이 기록된 이후이기 때문에 새로운 depth 값이 그대로 저장됩니다. 실제로는 다음과 같은 모습입니다:

오른쪽 이미지에서 나뭇잎들이 여전히 alpha testing을 적용하고 있는 것을 볼 수 있지만, 마치 alpha testing이 전혀 없는 것처럼 서로를 가리고 있습니다. 이건 충분히 생각해보지 않았다면 꽤 예상 밖의 결과일 수 있으며, 아마도 원했던 동작은 아닐 것입니다!
다음은 테스트 앱에서 가져온 더 단순한 예시로, 어떤 일이 일어나고 있는지를 훨씬 더 명확하게 보여줍니다:

[earlydepthstencil]을 사용할 때 discard, depth export, UAV 등 Early-Z를 자동으로 비활성화시키는 요소들을 사용하지 않는다면, depth 쓰기가 활성화되어 있어도 일반적으로는 문제가 없습니다. 이런 경우에는 pixel shader가 depth 값을 어떤 방식으로든 수정할 가능성이 없기 때문에 depth test와 쓰기를 미리 수행해도 괜찮으며, render target 쓰기에는 이미 암묵적인 순서 보장이 존재합니다.
Forced Early-Z With UAVs And Depth Writes
UAV 및 Depth Write와 함께 강제로 Early-Z 사용하기
자, 이제 정말 복잡한 상황을 가정해봅시다. UAV를 사용하면서 동시에 depth write도 수행하고, 거기에 강제로 Early-Z까지 적용하고 싶다고 해봅시다. 이런 경우에는 어떤 일이 일어날까요? 간단히 말하면, pixel shader에서 UAV를 사용할 때와 마찬가지로, 겹치는 픽셀들 사이에서 여전히 race condition이 발생할 수 있습니다. Early-Z depth test와 업데이트는 이미 가려진 픽셀을 shader가 실행되기 전에 제거할 수는 있지만, 이는 해당 픽셀이 포함된 삼각형이 그것을 가리는 삼각형보다 나중에 제출된 경우에만 가능합니다. 삼각형들이 이런 순서로 제출되지 않았거나 서로 교차하는 경우, 더 가까운 픽셀과 더 먼 픽셀이 동시에 실행되며 서로 경쟁하게 됩니다.
다음은 이러한 상황이 실제로 어떻게 나타나는지를 보여주는 두 가지 예시입니다:

이건 좀 아쉬운 부분입니다. 왜냐하면 하드웨어 기반의 depth write를 활성화해야 할 경우, 결국 일반적인 고정 기능 방식의 render target 쓰기에 의존할 수밖에 없다는 뜻이기 때문입니다. 이런 상황은 depth prepass를 필수로 요구하게 만들 수 있고, 결과의 정확성을 위해 그런 요구사항이 생기는 건 이상적인 구조는 아닙니다.
하지만… 아직 우리가 살펴보지 않은 또 다른 선택지가 하나 남아 있습니다.
Rasterizer Order Views/Fragment Shader Interlock
마무리하기 전에, ROVs와 FSI가 Early-Z + depth write 상황에서 어떤 역할을 하는지 간단히 짚고 넘어가고자 합니다. ROV는 두 가지를 보장합니다:
- 동일한 픽셀 좌표에 매핑되는 pixel shader들이 ROV 리소스에 대해 겹치는 접근을 하지 않도록 하며, atomic 연산 없이도 안전하게 read/modify/write 작업을 수행할 수 있게 해줍니다.
- ROV 리소스에 대한 접근은 API 제출 순서대로 이루어지며, 이는 render target 쓰기나 블렌딩 동작과 유사합니다.
depth write를 사용하지 않는 경우, ROV + [earlydepthstencil] 조합에서 얻는 결과는 단순합니다: depth test를 통과한 픽셀만 쓰기를 수행하며, 그 쓰기는 제출 순서대로 발생합니다. 이는 OIT나 유사한 기법에 매우 유용합니다.
하지만 depth write가 활성화된 경우는 어떨까요? 이 경우에도 우리가 기대하는 전통적인 render target 쓰기/블렌딩과 유사한 결과를 얻을 수 있어야 합니다. 즉, 최종적으로 Z 테스트를 통과한 가장 가까운 픽셀의 쓰기가 마지막으로 적용되어야 합니다. 이러한 Z 순서 결과를 얻으려면 Early-Z 테스트가 API 제출 순서대로 수행되어야 하며, 두 픽셀이 겹칠 경우 다음 두 가지 중 하나가 발생해야 합니다:
- “더 가까운” 픽셀이 먼저 Z 연산을 수행하고, “더 먼” 픽셀의 shader 스레드는 실행되기 전에 제거됨
- “더 먼” 픽셀이 먼저 Z 연산을 수행하더라도, ROV 규칙에 따라 “위에 있는” 픽셀의 쓰기가 두 번째로 실행되어 최종적으로 보이게 됨
생각해보면, 이는 우리가 ROV 대신 일반적인 render target 쓰기를 사용할 때와 동일한 상황입니다. 쓰기는 제출 순서대로 발생하고, 올바른 결과를 얻기 위해서는 Z 연산도 제출 순서대로 수행되어야 합니다.
제가 AMD RX 7900 XT에서 간단한 테스트를 해본 결과, 이 방식이 실제로 잘 작동하는 것으로 보입니다! 하지만 이 글에서 언급된 모든 내용과 마찬가지로, 여러분이 사용하는 하드웨어에서 직접 검증해보는 것이 좋습니다. 저는 더 복잡한 장면에서도 테스트를 성공적으로 수행했으며, 테스트 앱에서도 기대한 대로 작동했습니다:

하지만 이 접근 방식 역시 다른 Early-Z 강제 pixel shader들과 마찬가지로 discard나 depth export와는 호환되지 않습니다. 그리고 물론 ROV 자체가 가지는 고유의 성능 오버헤드도 존재합니다. 이 오버헤드는 사용하는 하드웨어, 병렬 처리 수준, overdraw 양, 그리고 shader 내에서의 critical section 크기에 따라 달라집니다.
Summary and Conclusion
요약 및 결론
정리하자면, 아래 표는 Early-Z를 강제로 활성화하지 않았을 때 드라이버가 사용하는 예상되는 암묵적 동작을 정리한 것입니다:
Shader 기능 Depth Write 활성화 여부 예상 Early-Z 동작
| 없음 | 아니오 | Early-Z 가능성이 높음 |
| discard | 아니오 | Early-Z 가능성이 높음 |
| depth export | 아니오 | 항상 Late-Z |
| conservative depth export | 아니오 | depth test 방향과 반대일 경우 Early-Z 가능성, 아니면 Late-Z |
| UAV 쓰기 | 아니오 | 항상 Late-Z |
| 없음 | 예 | Early-Z 가능성이 높음 |
| discard | 예 | 추가적인 Late-Z write/test와 함께 Early-Z 가능성 있음, Early PS culling 비율 감소 가능성 있음 |
| depth export | 예 | 항상 Late-Z |
| conservative depth export | 예 | depth test 방향과 반대일 경우 Early-Z + Late-Z 가능성, Early PS culling 비율 감소 가능성 있음. 아니면 Late-Z |
| UAV 쓰기 | 예 | 항상 Late-Z |
그리고 이제 [earlydepthstencil]을 사용하여 Early-Z를 강제로 적용했을 때의 예상 동작도 함께 정리해보겠습니다:
Shader Features
Depth Write 활성화 여부 예상 동작
| 없음 | 아니오 | 올바른 결과 |
| discard | 아니오 | 올바른 결과 |
| depth export | 아니오 | depth export는 무시됨! |
| conservative depth export | 아니오 | depth export는 무시됨! |
| UAV 쓰기 | 아니오 | depth test를 통과한 픽셀만 UAV 쓰기를 수행하지만, 겹치는 픽셀 간에는 순서가 없음 |
| ROV 쓰기 | 아니오 | depth test를 통과한 픽셀만 UAV 쓰기를 수행하며, 쓰기는 제출 순서대로 발생함 |
| 없음 | 예 | 올바른 결과 |
| discard | 예 | 잘못된 결과 — discard는 Z 쓰기에 영향을 주지 않음! |
| depth export | 예 | depth export는 무시됨! |
| conservative depth export | 예 | depth export는 무시됨! |
| UAV 쓰기 | 예 | UAV 쓰기는 Z 순서를 따르지 않음! 겹치는 픽셀 간에 경쟁 조건 발생 가능 |
| ROV 쓰기 | 예 | ROV 쓰기는 기대한 Z 테스트 결과와 일치함 |
이 표들과 본문이 드라이버가 언제 자동으로 Early-Z를 적용하는지에 대한 직관을 제공하고, pixel shader에서 Early-Z를 강제로 적용했을 때 정확히 어떤 일이 일어나는지를 이해하는 데 도움이 되었기를 바랍니다. 읽어주셔서 감사합니다!
- AMD는 VK_AMD_shader_early_and_late_fragment_tests라는 Vulkan 확장을 제공하는데, 이는 Early-Z를 강제로 적용할 때 depth export가 없어야 한다는 제약을 완화해줍니다. AMD 하드웨어는 Early-Z와 Late-Z 테스트를 모두 지원할 수 있기 때문에, 초기 프래그먼트 제거에는 Early-Z를 사용하면서도 depth 쓰기는 fragment shader가 (보수적으로) depth를 export한 이후에 수행할 수 있도록 허용합니다.
- 하드웨어 기반의 depth write 및 테스트를 생략하고 싶다면, 일부 depth 비트와 나머지 “payload” 비트를 결합하여 atomics를 사용할 수 있습니다. 이는 visibility buffer와 함께 사용할 때 가장 현실적인 방법이며, 64비트 atomics를 사용하더라도 실제로는 약 32비트 정도의 payload만 사용할 수 있습니다.