HZB Occlusion Culling
HZB란? depth buffer의 Mipmap 이다.
원본 depth buffer에서 downsampling을 해서 만들되, 상위 4 texel의 min / max 값을 가지고 있다.
reversed-Z 여부에 따라 min/max 값 결정한다. (필요에 따라 둘 다 저장)

HZB 사용처
- Occlusion culling
- Screen Space Reflections ( screen space ray casting 가속을 위해)
- Volumetric Fog
보통 HZB에 대해 뎁스테스팅은 Compute Shader에서 AABB 로만 보수적으로 진행한다.
결과물을 indirct draw call arguments 버퍼를 만들어 저장해두고, 그 후, 결과물을 Indirect Draw Call을 호출하여 draw를 진행한다.
HZB occlusion test는 보통 AABB pixel 크기에 맞는 Mip level을 계산하여 해당 레벨의 텍스쳐에서 테스팅하여 진행한다. (이 경우 AABB는 4 texel안에 위치하게 된다.)
대략 miplevel은 log2 함수에 비례한다. (정확한 계산은 구현에 따라 달라짐)
// MIP level selection example
int mipLevel = floor(log2(max(AABB.pixelWidth, AABB.pixelHeight)));
적당한 Mip level이 선택되면, HZB 4 texel 과 AABB의 최소값 (카메라와 제일 가까운 값) 을 비교하여 모든 texel이 테스팅을 통과하면 컬링, 혹은 컬링이 안되는 것이다.
⇒ 이는 매우 보수적으로 컬링하는 방법.
컬링 안된 object만 렌더링한다.
문제점
HZB를 만들기 위해서는 depth buffer가 필요한데, depth buffer를 만들기 위해서는 draw를 해야한다.
모든 object를 draw하면 occlusion culling의 의미가 없게된다.
해결책 1 : Occluder 수동 선택
하나의 해결책으로는 매우 큰 오브젝트들만 몇개 뽑아서 occluder로 사용하는 것이다. 보통 이는 아티스트에 의해 수동으로 선택되는데, 보통 큰 건물이나 지형이나 벽 등을 선택한다.
그래서 이 선택된 occluder들을 depth prepass에서 먼저 렌더링 한 후에 HZB를 빌드해서 사용하는 방식이다.

어쌔신 크리드 Unity에서 NSight로 캡쳐된 Depth prepass이미지
해결책 2 : Depth Buffer Reprojection
많은 시나리오에서 이전 프레임에서 보인 물체는 대부분 현재 프레임에서도 보인다. 여기서 얻은 아이디어가, 현재 프레임의 depth buffer를 계산하기 위해 이전 프레임의 depth buffer를 reprojection 하는 것이다.
이는 Depth Buffer Reprojection이라고 부른다.
이 방식의 장점은 수동으로 occluder를 설정해줄 필요가 없다는 것이다. 하지만 occluder방식과 함께 사용하면 더 좋은 결과를 낼 수 있다.
어쌔신 크리드 unity에서도 이 방식을 사용하였다.


이러한 장점들에도 불구하고 depth reprojection 방식은 한계를 가지고 있는데, 정밀도 문제이다. HZB 구축이 depth buffer approximation에 의존하고 있어서 컬링 프로세스가 완전 보수적이지 않다. (완벽하지 않다.) 이는 visible popping, 즉, 움직이는 물체가 갑자기 나타나는 현상이 발생하기도 한다.
Two-Pass Solution
위 두 가지 방식의 장점을 적절히 합친 솔루션이 있다. depth reprojection에서 이전 프레임을 활용한다는 아이디어를 사용하지만 부정확한 정보를 해결한 방식.
depth buffer 를 reprojection하는 대신에, 이전 프레임에서 보였던 오브젝트만 draw하는 방식이다. 이렇게 하려면 frame 시작단에서 이전에 보였던 물체들만 렌더링하는 prepass 과정이 필요하다. 이 오브젝트들은 현재 프레임의 occluder로써 완벽한 후보들이다. (물론 극단적인 카메라 변환이 없다면 말이다.)
이제부터 이 prepass를 First Pass라고 부른다. depth prepass와 비교해서, 이 First Pass는 depth만 저장하지는 않고, 기존 패스처럼 G-Buffer도 같이 렌더링 한다. (Nanite의 경우 visibility buffer에 씀)
First Pass 이후에는 남은 culling process는 기존 HZB occlusion culling과 비슷하다.
이 방식을 Two-Pass Occlusion Culling이라고하며, UE5 Nanite에서 소개되면서 유명해졌다.
이름에서 그렇듯이 two-pass occlusion culling은 scene object들을 두 그룹으로 나누어서, 각 패스에서 렌더링된다. 각 패스는 먼저 compute shader dispatch로 inirect draw call argument들을 채우고, 이어서 indirect draw call이 실행된다. 하드웨어에서 지원된다면 Multi Draw Count Indirect도 사용될 수 있다.
이 방식은 GPU-driven 방식에 아주 잘 어울리는 방식이다.


Nanite Two Pass Culling에서 사용 중인 First Pass, Second Pass. (안에는 클러스터 처리때문에 더 복잡함)
First Pass
이전 프레임에서 보였던 물체들을 처리하는 패스이다.
이를 처리하기 위해서 처리하는 object 개수만큼의 compute shader thread를 dispatch 시킨다. 추가로 각 스레드는 Frustum Culling과 LOD selection을 진행한다.
이전에 안보였던 물체는 모두 스킵한다.
이 compute shader의 결과는 GPU buffer에 indirect draw call argument로 저장된다.
이 indirect draw call argument가 만들어지고 나서는 indirect draw call이 실행된다. object 의 visibility를 추적하기 위해서 Visibility Buffer라는 GPU Buffer가 사용된다. (Nanite 의 visibility buffer랑은 다른개념. second pass에서 사용하기 위함) 이 버퍼의 element는 씬의 각 object를 의미하며, 0 또는 1 값으로 object의 visibility를 의미한다.
...
// Read object's visibility from the previous frame
bool visible = visibilityBuffer[drawIndex];
// [Optional] Check if previously visible object
// is frustum culled in the current frame
if (visible)
{
bool frustumCulled = isFrustumCulled(...);
visible &&= !frustumCulled;
}
// Only object that was visible in the
// previous frame should be drawn in the first pass
bool shouldDraw = visible;
if (shouldDraw)
{
// [Optional] Select LOD
...
// Fill indirect draw call arguments
IndirectDrawArgs drawArgs;
...
drawArgs[drawArgsIndex] = drawArgs;
}
첫번째 패스가 끝나면 , 결과 depth buffer로부터 HZB 가 만들어진다.
이는 reproject방식에 비하면 완전히 conservative하다.
Second Pass
Second pass에서는 다시 한번 compute shader가 object개수만큼 compute shader thread가 실행되는데, frustum culling, LOD selection에 추가로 HZB occlusion culling이 실행된다.
이번 dispatch의 결과로는 first pass에서 그려지지 않은 물체 중에 culling을 모두 통과하여 visibile 이라고 판단된 물체들의 indirect draw call argument이다. 역시나 이후 indirect draw call이 실행된다.
첫 번째 패스에서 그려진 물체를 다시 그리는 것을 막기위해 first pass에서 채워줬던 visibility buffer의 정보를 사용한다. 추가로, visibility buffer는 다음 프레임을 위해서 second pass에서의 결과물도 업데이트해준다.
...
// [Optional] Check if object is frustum culled in the current frame
bool frustumCulled = isFrustumCulled(...);
bool visible = !frustumCulled;
// Check if object is occlusion culled in the current frame
if (visible)
{
bool occlusionCulled = isOcclusionCulled(...);
visible &&= !occlusionCulled;
}
// Only object that is visible in the current frame
// and was not drawn in the first pass should be drawn in the second pass
bool shouldDraw = visible && !visibilityBuffer[drawIndex];
if (shouldDraw)
{
// [Optional] Select LOD
...
// Fill indirect draw call arguments
IndirectDrawArgs drawArgs;
...
drawArgs[drawArgsIndex] = drawArgs;
}
// Fill visibility buffer for the next frame
visibilityBuffer[drawIndex] = visible;
결론
Two-Pass occlusion culling은 아주 효과적인 최적화 방식이다. 그러나 카메라 기준으로 매우 빠른 물체들이 있는 상황에서는 한계가 있다. 그러나 이런 상황은 흔치않고, cut scene transition 정도에서는 자주보인다. 이러한 상황에서는 transition 후 프레임에 퍼포먼스 이슈가 발생할 수 있다. 이러한 문제를 다루기위해서는 추가적은 depth prepass를 넣는 방식도 사용가능하다.
이 방식은 또한 meshlet과 triangle occlusion culling에서도 사용가능하나, 딱히 노력에 비해 효과가 좋진않다. 이 방식은 forward rendering, deferred rendering, deferred material, visibility buffering 에서 모두 사용 가능하다. geometry가 밀집된 씬에서는 deferred materials 와 visibility buffering가 효과가 좋다. 최근 엔진들은 모두 GPU-driven한 방식을 사용 중이기 때문에 각 엔진들은 적당한 occlusion culling 방식을 사용 중이며 성능상, 그리고 간편함 상 이득을 취하고 있다.
Reference
- Two-Pass Occlusion Culling by Milos Kruskonja
https://medium.com/@mil_kru/two-pass-occlusion-culling-4100edcad501
'Computer Graphics' 카테고리의 다른 글
| Multi-Scattering BRDF (4) | 2025.07.19 |
|---|---|
| Image Based Lighting (3) | 2025.07.17 |