이런 분이 읽으면 좋습니다!

  • “Nanite가 뭐가 좋은 거야?”부터 궁금한 분 — 쉬운 설명에서 시작해 점점 깊어집니다
  • UE5 Nanite가 내부에서 어떤 순서로 무엇을 하는지 궁금한 분
  • CPU · GPU 양쪽에서 Nanite 비용이 어디서 발생하는지 알고 싶은 분

이 글로 알 수 있는 내용

  • 기존 LOD 방식의 한계와 Nanite가 그것을 어떻게 해결하는지
  • Nanite Cluster가 무엇이고, 파이프라인 전체에 어떻게 녹아 있는지
  • 사전 준비 단계에서 MaterialPass까지의 전체 흐름
  • 프로파일링 데이터 기준 비용이 가장 큰 패스와 그 이유


배경 — 기존 LOD의 한계

게임에서 시네마틱 품질의 바위나 조각상을 렌더링하려면, 원본 3D 에셋의 폴리곤 수가 수백만~수억 개에 달할 수 있다. 화면을 가득 채우는 오브젝트라면 몰라도, 멀리 있거나 일부만 보이는 오브젝트까지 전체 폴리곤을 계산하는 건 명백한 낭비다. 기존 게임 엔진은 이 문제를 LOD(Level of Detail)로 해결해왔다.

기존 방식 — 수작업 LOD

  • 방식동일 메시를 여러 해상도로 수작업 제작
  • 전환거리 기준으로 LOD0 → LOD1 → LOD2 교체
  • 문제제작 비용 증가 + 팝핑 아티팩트

아티스트가 LOD를 직접 만들어야 하고, 전환 시점에 메시가 갑자기 바뀌는 팝핑(popping)이 발생한다. 에셋 수가 많을수록 관리 비용이 선형으로 증가한다.

Nanite — 자동 가상화

  • 방식클러스터 계층으로 자동 LOD 대체
  • 선택화면 픽셀 밀도 기반 런타임 선별
  • 결과수억 폴리곤 → 실시간 렌더링

LOD를 수작업으로 만들 필요가 없다. 에셋을 그대로 임포트하면 Nanite가 화면에서 실제로 필요한 디테일만 선별해 렌더링한다.

핵심 아이디어 — 세 가지로 이해하기

Nanite가 이를 가능하게 하는 핵심 아이디어는 세 가지다. 기술적인 세부사항에 들어가기 전에 이 직관을 잡아두면, 이후 파이프라인 전체가 훨씬 자연스럽게 읽힌다.

A

메시를 작은 덩어리로 쪼개서 필요한 것만 선별한다

메시 전체를 한 번에 처리하는 대신, 최대 128개 삼각형의 덩어리(Cluster)로 분해한다. 이 클러스터들이 계층 구조(BVH)를 이루기 때문에, 카메라에서 멀수록 더 적은 클러스터를 사용하는 것이 자연스럽게 가능하다. 수억 폴리곤이라도 화면에 실제로 기여하는 클러스터만 처리한다.

메시 → 클러스터 계층 BVH 순회로 필요한 것만 선별
B

"어디가 보이냐"를 싸게 먼저 기록하고, 그 다음 비싼 색칠을 한다

전통적인 렌더링은 삼각형을 처리하면서 바로 머티리얼(색상, 거칠기, 법선 등)을 계산한다. 문제는 나중에 다른 오브젝트에 가려져 보이지 않을 픽셀도 계산해버린다는 점이다.

Nanite는 먼저 Visibility Buffer에 "이 픽셀은 어느 클러스터의 어느 삼각형"인지만 기록한다. 머티리얼 계산은 그 다음 별도 패스에서만 수행한다. 화면에 실제로 보이는 픽셀에 대해서만 머티리얼이 실행되므로 낭비가 없다.

래스터 → VisibilityBuffer 별도 머티리얼 셰이딩 패스
C

이전 프레임으로 빠르게 컬링하고, 틀린 것을 현재 프레임으로 보정한다

컬링(안 보이는 것을 솎아내는 과정)의 이상적인 기준은 "현재 프레임에서 실제로 보이는지"다. 하지만 이를 정확히 알려면 렌더링을 해봐야 한다 — 닭이 먼저냐 달걀이 먼저냐의 문제다.

Nanite는 이를 두 패스로 해결한다. Main Pass에서 이전 프레임 Depth 정보를 기준으로 빠르게 컬링·렌더링한다. 그 결과로 만든 현재 프레임 Depth를 이용해 Post Pass에서 Main Pass가 잘못 잘라낸 오브젝트를 보정한다.

이전 프레임 HZB → Main Pass 현재 프레임 HZB → Post Pass 보정
이 글의 나머지 구성

이제 위 세 아이디어를 실제 코드 수준으로 파헤친다. Cluster 계층 구조 → 사전 준비 단계 → Two-Pass Occlusion Culling → Visibility Buffer → Base Pass → GPU/CPU 비용 → 각 패스의 상세 동작 순서로 진행된다. 앞의 직관을 떠올리며 읽으면 각 단계가 왜 존재하는지 자연스럽게 연결된다.

구조 — Cluster가 모든 것의 기반

Nanite는 UE5의 가상화 지오메트리(Virtualized Geometry) 시스템이다. "가상화"라는 단어는 가상 메모리나 버추얼 텍스처에서 쓰는 개념과 같다. 실제로 필요한 데이터만 그때그때 메모리에 올리고, 나머지는 디스크에 두는 방식이다. Nanite는 이 개념을 지오메트리(폴리곤)에 적용한다. 수억 개의 폴리곤을 모두 메모리에 올리는 대신, 화면에 보이는 부분만 런타임에 스트리밍해서 처리한다.

클러스터(Cluster)란?

클러스터는 메시 전체가 아니라 메시 표면의 일부분이다. 하나의 StaticMesh는 에디터에서 임포트할 때 자동으로 수백~수천 개의 클러스터로 분해된다. 각 클러스터는 최대 128개의 인접한 삼각형 묶음으로, 메시 표면을 이어붙인 조각이라고 생각하면 된다.

"클러스터"라는 이름이 붙은 이유는 공간적으로 가까운 삼각형들을 한 묶음(cluster)으로 모았기 때문이다. 무작위로 나누는 게 아니라 위치가 인접한 삼각형끼리 그룹을 짓기 때문에, 클러스터 하나는 메시 표면의 연속된 한 영역을 나타낸다. 덕분에 클러스터 단위로 "이 영역이 화면에 보이는가"를 판단할 수 있다.

에디터 빌드 시 클러스터는 어떻게 만들어지나

클러스터 생성은 런타임이 아니라 에디터에서 에셋을 임포트·빌드할 때 일어난다. 엔진 소스(NaniteBuilder.cpp)를 기준으로 흐름을 정리하면 다음과 같다.

① 인접 그래프 구성

먼저 모든 삼각형의 엣지를 해싱해 어떤 삼각형끼리 엣지를 공유하는지 파악한다. 이렇게 하면 삼각형을 노드, 공유 엣지를 간선으로 하는 그래프가 만들어진다.

이 그래프만 있으면 위상적으로 연결된 삼각형은 찾을 수 있다. 그런데 위상적으로는 떨어져 있지만 공간적으로는 가까운 경우도 있다. 예를 들어 입술 안쪽과 바깥쪽처럼, 실제 3D 위치는 가깝지만 메시 연결로는 아주 멀리 떨어진 삼각형들이다. 이런 경우도 같은 클러스터에 넣으면 바운딩 볼륨이 더 작아져 컬링 효율이 높아진다.

그래서 Morton 코드를 사용한다. 각 삼각형의 중심점을 Morton 코드로 변환하면 3D 좌표가 공간적 근접도를 유지한 채 1D 숫자로 바뀐다. 이 숫자로 정렬하면 공간적으로 가까운 삼각형이 리스트에서도 가까이 모인다. O(N²) 비교 없이 O(N log N) 정렬만으로 공간적 이웃을 빠르게 찾을 수 있는 것이다. 이렇게 찾은 공간 근접 이웃 쌍에도 그래프 간선을 추가한다.

최종 그래프는 두 종류의 간선을 갖는다. 엣지 공유(위상 연결)는 강한 가중치, 공간 근접은 약한 가중치. 이 가중치가 뒤에 나올 파티셔닝에서 "반드시 붙어 있어야 하는 삼각형 vs 가능하면 붙여줬으면 하는 삼각형"의 우선순위를 결정한다.

② METIS로 그래프 파티셔닝

그래프를 N개의 파티션으로 나눠야 한다. 단순히 바운딩 박스로 공간을 자르거나 삼각형 인덱스 순서로 나누면, 파티션 경계가 메시 표면 위를 불규칙하게 가로지르게 된다. 경계가 많을수록 클러스터의 바운딩 볼륨이 커지고, 컬링이 부정확해진다.

METIS는 이 문제를 위해 만들어진 그래프 파티셔닝 라이브러리다. METIS의 목표는 두 가지다.

  • 파티션 간 잘리는 엣지(간선) 수 최소화 — 즉, 클러스터 경계를 최대한 메시의 자연스러운 경계(표면이 이어지지 않는 부분)에 맞춘다
  • 각 파티션 크기 균등하게 유지 — 특정 클러스터만 삼각형이 몰리지 않도록 128개 제한에 맞게 균형을 잡는다

여기서 가중치가 의미를 갖는다. 강한 가중치(엣지 공유) 간선을 자르는 것은 큰 비용이 된다. METIS는 이 비용을 최소화하려 하기 때문에, 엣지를 공유하는 삼각형은 웬만하면 같은 파티션에 남는다. 약한 가중치(공간 근접) 간선은 "이 두 삼각형도 가능하면 같이 묶어 달라"는 힌트 역할을 한다.

최종 결과물은 메시 표면이 연속적으로 이어진 조각들로, 각 조각이 하나의 클러스터(최대 128 삼각형)가 된다. 표면이 자연스럽게 모여 있으니 바운딩 볼륨도 빡빡하게 맞고, 런타임 컬링 정확도가 올라간다.

③ 계층 생성 — 이것이 곧 LOD다

리프 클러스터(원본 메시에서 직접 분할된 것)가 완성되면, 이번에는 이 클러스터들을 8~32개씩 묶어 부모 클러스터를 만든다. 부모 클러스터는 자식들을 합친 뒤 폴리곤 수를 줄여야 한다. 이때 쓰는 것이 QEM(Quadric Error Metrics)이다.

QEM은 엣지를 하나씩 붕괴(collapse)시켜 폴리곤을 줄이는 메시 단순화 알고리즘이다. 단순히 버텍스를 제거하는 게 아니라, 각 버텍스가 인접한 평면들로부터 얼마나 벗어나는지를 행렬(Quadric)로 추적한다. 엣지를 붕괴할 때 이 오차가 가장 작은 엣지부터 제거하므로, 평평한 부분은 많이 줄이고 날카로운 실루엣은 최대한 보존한다. 부모 클러스터는 이렇게 단순화된 뒤 다시 128개 이하로 분할된다.

이 과정을 반복하면 아래는 원본 해상도, 위로 갈수록 점점 단순화된 트리가 만들어진다. 이것이 곧 Nanite의 LOD다. 전통적인 LOD처럼 메시 전체 단위로 교체되는 게 아니라, 클러스터 단위로 독립적으로 선택된다. 같은 바위 메시라도 카메라에 가까운 쪽은 리프 클러스터(원본 해상도)를, 멀거나 가려진 쪽은 상위 클러스터(단순화된 버전)를 동시에 렌더링할 수 있다.

각 클러스터는 빌드 시 QEM에서 계산한 LODError 값을 갖는다. 런타임에 이 값과 화면 픽셀 밀도를 비교해 "이 클러스터의 오차가 1픽셀 미만이면 충분히 정확하다"고 판단한다.

④ BVH 구성과 런타임 GPU 순회

클러스터 계층이 완성되면 마지막으로 BVH(Bounding Volume Hierarchy)를 얹는다. BVH는 클러스터들을 공간적으로 묶은 트리 구조다. 각 노드가 자신의 하위 클러스터를 모두 감싸는 바운딩 스피어를 갖는다. 덕분에 루트 노드부터 "이 범위가 카메라에 안 보이면 하위 전체 스킵"이 가능해진다.

BVH를 만들 때 어떻게 노드를 나눌지가 중요하다. 단순히 중간값으로 반으로 나누면 한쪽이 폴리곤이 몰리거나 바운딩 볼륨이 비효율적으로 커질 수 있다. Nanite는 SAH(Surface Area Heuristic)를 쓴다. SAH의 핵심 아이디어는 "광선이 바운딩 볼륨에 맞을 확률은 그 표면적에 비례한다"는 것이다. 분할 후 좌우 노드의 (표면적 × 자식 수)의 합이 최소가 되는 지점을 찾아 자르면, 컬링에서 탐색 비용이 통계적으로 최소화된다.

런타임에 GPU가 이 BVH를 순회하는 것은 레이 트레이싱 전용 하드웨어(DXR/RTX)를 사용하지 않는다. 일반 Compute Shader로 작성된 코드다. 대신 "Persistent Thread" 방식을 쓴다. 고정된 수의 Compute Shader Workgroup이 계속 살아 있으면서 BVH 노드 큐에서 작업을 꺼내 처리한다. GPU Warp(wave)가 낭비 없이 계속 돌아가도록 설계된 방식으로, 일반 GPU 코드지만 GPU 병렬성을 최대한 활용한다.

클러스터 계층 구조 — 왜 계층이 필요한가

클러스터가 만들어지고 나면 끝이 아니다. 앞서 ③에서 설명했듯, 리프 클러스터들을 묶어 단순화한 부모 클러스터를 반복해서 만들면 트리 구조가 생긴다. 이 트리 자체가 LOD 계층이다.

아래를 보면 동일한 메시가 4단계로 표현된다. 리프(Level 0)는 원본 해상도, Level 1은 8~32개 리프를 병합·단순화한 버전, 위로 갈수록 더 단순해져서 루트(최상위)는 메시 전체를 아주 단순하게 표현한다.

에디터 빌드 시 생성되는 구조 (에셋에 저장, 런타임 불변)
StaticMesh 원본 메시 (N 삼각형) → METIS 분할 Leaf Cluster ×수백~수천 각 ≤128 삼각형
LOD 계층 Level 0 (Leaf) 원본 해상도 Level 1 단순화 Level 2 ··· Root
BVH 위 계층 전체를 감싸는 공간 트리 — 런타임 컬링용
루트에 가까운 클러스터일수록 항상 메모리에 상주(Resident). 리프에 가까울수록 필요할 때만 스트리밍 로드.

런타임에 GPU는 이 BVH를 루트부터 순회한다. 각 노드에서 "이 클러스터의 LODError가 화면 픽셀 밀도 기준으로 1픽셀 미만인가?"를 확인한다. 충분히 작으면 이 클러스터를 그리고 자식 탐색을 멈춘다. 크면 자식으로 내려간다. 결과적으로 같은 메시의 서로 다른 영역이 다른 LOD 레벨의 클러스터로 동시에 렌더링된다.

소스 기준으로 이 판단은 꽤 직설적이다. UE는 뷰마다 LODScale를 미리 계산해 두고(NaniteShared.cppFPackedView::UpdateLODScales), 컬링 셰이더(NaniteClusterCulling.usf)에서 "투영된 클러스터/노드의 화면상 크기"LODError × LODScale를 비교한다. 대략적인 형태는 다음과 같다.

LODScale = ViewToPixels / MaxPixelsPerEdge

// 노드: 더 내려갈지 결정
should_visit_child = projected_error <= MaxParentLODError * LODScale * UniformScale

// 클러스터: 지금 이 클러스터를 그릴지 결정
small_enough_to_draw = projected_error > LODError * LODScale * UniformScale

의미를 풀면 이렇다. 화면에 크게 보이는 영역은 projected_error가 크므로 더 세밀한 자식 클러스터까지 내려가고, 멀거나 작게 보이는 영역은 상위 클러스터에서 멈춘다. 이때 기준이 되는 MaxPixelsPerEdger.Nanite.MaxPixelsPerEdge와 뷰별 배율(업스케일링, 동적 해상도, 품질 스케일)의 영향을 받는다. 즉 Nanite의 런타임 LOD는 "거리" 하나로 자르는 전통적인 LOD가 아니라, 현재 뷰에서 허용하는 픽셀 오차 한도를 만족할 때까지 BVH를 내려가는 방식이다.

클러스터 하나에 무엇이 들어 있는가

클러스터는 렌더링에 필요한 모든 정보를 스스로 갖고 있다.

지오메트리 데이터

  • 삼각형최대 128개 (인덱스 배열)
  • 버텍스최대 256개 (위치·법선·UV 등)
  • LODErrorQEM 단순화 오차 — LOD 선택 기준
  • BoundingSphere컬링용 바운딩 스피어

머티리얼 정보

  • RasterMaterial래스터라이즈 단계용 ID
  • ShadingMaterial셰이딩 단계용 ID
  • MaterialRanges삼각형별 머티리얼 범위

하나의 클러스터가 여러 머티리얼 슬롯에 걸칠 수 있다. 최대 64개.

RasterMaterial — 래스터라이즈 단계에서만 쓰는 머티리얼

Visibility Buffer에 쓸 내용은 Depth | ClusterIndex | TriangleIndex뿐이다. 이걸 기록하는 데 실제 머티리얼(색상, 거칠기 등)은 전혀 필요 없다. 그래서 대부분의 클러스터는 래스터라이즈 시 아무 연산도 하지 않는 저렴한 Default 머티리얼을 쓴다.

단, 머티리얼이 버텍스 위치 자체를 바꾼다면 얘기가 달라진다. WPO(World Position Offset)는 머티리얼 내에서 버텍스를 이동시킨다. Default 머티리얼로 래스터라이즈하면 버텍스가 원래 위치에 있는 채로 기록되므로 실루엣이 틀어진다. PDO(Pixel Depth Offset), Masked도 마찬가지로 실제 머티리얼이 필요하다. 이런 클러스터만 RasterMaterial로 실제 머티리얼 ID를 갖고, 나머지는 Default를 가리킨다.

ShadingMaterial — GBuffer를 채우는 실제 머티리얼

Visibility Buffer가 완성된 뒤 BasePass에서 비로소 GBuffer를 채운다. 이 단계에서는 픽셀마다 실제 BaseColor, Normal, Roughness, Metallic 등을 계산해야 한다. ShadingMaterial은 항상 실제 머티리얼이다. Visibility Buffer에서 ClusterIndex와 TriangleIndex를 읽어 어떤 클러스터의 어떤 삼각형인지 알아낸 뒤, 해당 클러스터의 ShadingMaterial로 픽셀 셰이더를 실행해 GBuffer를 채운다.

요약하면: 래스터라이즈 단계는 "어디에 무엇이 있나"를 기록하는 단계라 머티리얼이 최소한으로 필요하고, 셰이딩 단계는 "그것의 생김새를 계산"하는 단계라 항상 실제 머티리얼이 필요하다.

Page 스트리밍 — 지오메트리도 가상화된다

클러스터 계층 전체를 처음부터 VRAM에 올려두는 건 불가능하다. 수억 폴리곤짜리 에셋이 씬에 수백 개 있을 수 있기 때문이다. Nanite는 이를 버추얼 텍스처와 동일한 방식으로 해결한다.

버추얼 텍스처와의 비교

Virtual Texture

  • 단위Tile (예: 128×128 픽셀)
  • 데이터픽셀 색상 데이터
  • 요청 시점UV 샘플링 시 Feedback

Nanite Streaming

  • 단위Page (~수십 KB, 여러 클러스터 묶음)
  • 데이터버텍스 위치·법선·UV·인덱스 등
  • 요청 시점래스터라이즈 중 미로드 클러스터 접근 시

런타임 스트리밍 흐름

GPU가 래스터라이즈 중 아직 로드되지 않은 클러스터에 접근하면, 셰이더 내에서 해당 Page 번호를 RequestPageRange 버퍼에 기록한다. 프레임 마지막에 Readback 패스가 이 버퍼를 CPU로 읽어온다. CPU 스트리밍 매니저는 요청된 Page를 디스크에서 비동기 로드해 다음 프레임에 VRAM에 올린다.

이번 프레임에는 해당 클러스터 대신 이미 메모리에 있는 상위 계층 클러스터(= 단순화된 버전)가 렌더링된다. 다음 프레임에 Page가 로드되면 자동으로 더 디테일한 클러스터로 전환된다.

루트에 가까운 클러스터(=가장 단순화된 버전)는 항상 VRAM에 상주한다. 어떤 상황에서도 최소한의 메시 형태를 렌더링할 수 있도록 보장하기 위해서다.

Cluster는 컬링·래스터라이즈·셰이딩 전 단계에서 기본 단위로 사용된다. 컬링은 클러스터 계층을 순회해 가시 클러스터만 선별하고, 래스터라이즈는 클러스터 단위로 SW/HW 방식을 결정하며, 셰이딩은 클러스터에서 TriangleIndex·Barycentric을 복원해 GBuffer를 채운다. 아래 파이프라인 전체에 걸쳐 Cluster라는 단어가 반복되는 이유가 여기에 있다.

Cluster-based Rendering
클러스터 단위 처리

메시 단위가 아닌 최대 128 삼각형의 클러스터 단위로 컬링·래스터라이즈·셰이딩이 이루어진다. Mesh Shader와 개념적으로 유사하지만 UE5는 Compute Shader 기반 SW 래스터라이저와 기존 VS·PS 기반 HW 래스터라이저를 조합해 사용한다.

Two-Pass Occlusion Culling
2패스 오클루전 컬링

이전 프레임 HZB로 MainPass 컬링 후, MainPass 결과로 만든 현재 프레임 HZB를 사용해 PostPass에서 새로 보이게 된 클러스터를 보정한다. "프레임 간 변화가 작다"는 가정 하에 고효율 근사 컬링을 달성한다.

Visibility Buffer Rendering
가시성 버퍼 렌더링

래스터라이즈 단계에서 픽셀당 Depth|ClusterIndex|TriangleIndex만 기록한다. 머티리얼 셰이딩은 이후 별도 패스에서 머티리얼별 타일을 묶어 수행. 픽셀보다 작은 삼각형의 Quad Helper-Lane 낭비 문제를 해소한다.

준비 단계 — 머티리얼 슬롯과 실행 키를 미리 만든다

준비 단계 — 머티리얼 슬롯과 실행 키를 미리 만든다

여기서 먼저 bin이 무엇인지부터 잡고 가는 게 좋다. Nanite에서 bin은 "같은 실행 경로를 쓰는 작업 묶음" 정도로 이해하면 된다. 화면의 모든 삼각형과 픽셀을 하나씩 따로 처리하면 상태 전환과 셰이더 분기가 너무 많아지므로, 비슷한 방식으로 처리될 것끼리 먼저 묶어 두고 나중에 한꺼번에 처리하려는 것이다.

Nanite는 이 묶음을 둘로 나눈다. Raster Bin은 "이 삼각형을 Visibility Buffer에 쓸 때 어떤 래스터 경로를 써야 하는가"를 기준으로 한 묶음이고, Shader Bin은 "나중에 Base Pass에서 이 픽셀을 어떤 머티리얼 셰이더 경로로 칠해야 하는가"를 기준으로 한 묶음이다. 즉 Raster Bin은 래스터 단계의 실행 키, Shader Bin은 셰이딩 단계의 실행 키다.

왜 이런 분리가 필요할까. Nanite의 래스터 단계는 모든 머티리얼을 똑같이 처리하지 않는다. 불투명하고 단순한 머티리얼은 거의 "주소만 기록하는" 가벼운 경로로 갈 수 있지만, Masked, WPO, PDO가 섞이면 픽셀별 clip 판단이나 depth 수정이 필요해져 programmable raster 경로를 타야 한다. 반대로 Base Pass에서는 BaseColor, Normal, Roughness 등을 실제로 계산해야 하므로, 머티리얼 인스턴스와 셰이더 permutation에 따라 material shading 경로가 달라진다. Nanite가 Raster Bin과 Shader Bin을 따로 두는 이유는, 래스터 단계와 셰이딩 단계가 같은 기준으로 묶이지 않기 때문이다.

Raster Bin과 Shader Bin을 왜 미리 정하나

GPU가 런타임에 매 삼각형, 매 픽셀마다 "이건 어떤 래스터 경로지, 어떤 셰이딩 경로지"를 다시 추론하면 분기와 테이블 조회가 너무 많아진다. 그래서 CPU가 씬 등록 시점에 머티리얼 슬롯별로 bin ID를 미리 계산해 두고, GPU는 그 결과만 읽어 바로 분류와 실행에 들어간다.

독자 입장에서는 이 단계를 "나중에 컬링, 래스터, Base Pass가 반복해서 참조할 실행 키를 미리 만들어 두는 준비 단계"라고 보면 된다.

Nanite는 런타임에 갑자기 머티리얼 bin을 계산하지 않는다. 프리미티브가 FScene에 등록될 때 미리 어떤 머티리얼 슬롯이 어떤 Raster Bin / Shader Bin에 속하는지 정리해 두고, 그 결과를 GPUScene 쪽 테이블로 넘긴다.

엔진 코드 이름으로는 여기에 MeshDrawCommand 캐싱과 FNaniteMaterialSlot 준비가 함께 걸려 있다. 다만 이 글에서 중요한 건 이름 자체보다 역할이다. 런타임 셰이더가 매 픽셀마다 "이 삼각형은 어떤 programmable raster 경로를 타야 하지, 어떤 material shading 경로를 타야 하지"를 다시 추론하지 않도록, CPU가 장면 등록 시점에 bin ID와 머티리얼 슬롯 인덱스를 미리 정해 둔다.

왜 이 단계가 Two-Pass보다 앞에 있어야 하나

Two-Pass Occlusion Culling은 프레임마다 다시 실행되는 GPU 컬링 단계지만, 머티리얼 슬롯과 bin ID 준비는 오브젝트 등록/변경 시점에만 갱신되는 사전 작업이다. 즉 독자 흐름으로도 "무엇을 그릴 준비가 되어 있나"가 먼저고, 그 다음이 "이번 프레임에 실제로 무엇이 보이나"다.

FNaniteMaterialSlot에는 RasterBin과 ShadingBin 정보가 함께 들어가며, GPU는 PrimitiveIndex * MaxMaterials + MaterialIndex 형태의 인덱스로 이 슬롯을 읽는다. 그래서 뒤쪽의 RasterBinning, ShadeBinning, BasePass는 이 준비된 키를 바로 소비할 수 있다.

프로파일러에서 보이는 FPrimitiveSceneInfo_CacheNaniteMaterialBins 비용은 바로 이 준비 단계의 흔적이다. 평상시에는 프레임마다 큰 비용이 들지 않고, 오브젝트 추가/삭제나 머티리얼 변경처럼 캐시가 무효화될 때만 다시 계산된다.

Two-Pass Occlusion Culling — 그릴 클러스터를 선별한다

Two-Pass Occlusion Culling — 그릴 클러스터를 선별한다

래스터라이즈 전에 "어떤 클러스터를 그려야 하는가"를 결정하는 단계다. 씬에 수백만 개의 클러스터가 있어도 실제로 화면에 기여하는 것은 일부뿐이다. 컬링은 이 목록을 GPU에서 완전히 처리한다. CPU는 컬링 결과를 알지 못한 채 Indirect DrawCall만 발행한다.

컬링은 세 단계로 내려간다. 인스턴스 → BVH 노드 → 클러스터. 큰 단위에서 점점 작은 단위로 좁혀가며, 각 단계에서 살아남은 것만 다음 단계로 넘긴다.

전체 흐름 — Main Pass · HZB 빌드 · Post Pass

Two-Pass 흐름
Main Pass 이전 프레임 HZB로 컬링 살아남은 클러스터 래스터라이즈 VisibilityBuffer (부분)
HZB 빌드 Main Pass Depth → 현재 프레임 HZB 생성
Post Pass 현재 프레임 HZB로 재검사 새로 보이게 된 클러스터 추가 래스터라이즈 VisibilityBuffer (완성)
Main Pass에서 Occluded로 판정된 인스턴스OccludedInstances 버퍼에 저장된다. Post Pass는 전체 씬이 아닌 이 버퍼만 재검사하므로 비용이 작다.

왜 두 패스인가. 이전 프레임 HZB는 카메라가 움직이거나 오브젝트가 새로 등장하면 틀릴 수 있다. Main Pass는 이 불완전한 HZB로 빠르게 컬링하고 래스터라이즈한다. 래스터라이즈 결과로 현재 프레임의 정확한 Depth를 얻을 수 있으니, Post Pass에서 이걸로 Main Pass가 틀리게 잘라낸 것들을 보정한다. 대부분의 프레임에서 "이전 프레임과 크게 다르지 않다"는 가정이 맞으므로 Post Pass에서 추가되는 클러스터는 소수다.

왜 InitViews가 아니라 여기서 실행되나

InitViews는 "무엇이 잠재적으로 보일 수 있는가"를 CPU 중심으로 준비하는 단계다. 여기서 뷰 uniform, GPUScene, primitive relevance, 그림자 준비, 인스턴스 컬링 입력 같은 렌더링 전제조건이 갖춰진다.

실제 Nanite의 Two-Pass Occlusion Culling은 그 다음 단계인 RenderPrepassAndVelocity → RenderNanite → Nanite::IRenderer::DrawGeometry → FRenderer::CullRasterize 안에서 실행된다. 이유는 간단하다. Main Pass 뒤에는 곧바로 현재 프레임에서 방금 만들어진 Depth/VisBuffer가 필요하고, 그걸로 BuildPreviousOccluderHZB를 만든 다음 바로 Post Pass를 이어서 돌려야 하기 때문이다. 즉 이 로직은 단순한 가시성 준비가 아니라, 실제 래스터 결과를 중간 산출물로 소비하는 렌더 패스라서 InitViews 내부에 둘 수 없다.

1단계 — Instance Culling CS

WorkGroup당 64개 스레드, 각 스레드가 인스턴스 1개를 담당한다. 소스(NaniteInstanceCulling.usf)를 기준으로 순서대로 다음 테스트를 통과해야 살아남는다.

컬링 테스트 순서

  • ① Visibility FlagHidden 여부, 게임/에디터/캡처 모드 구분
  • ② Frustum인스턴스 바운드 vs 뷰 프러스텀
  • ③ DistanceMax Draw Distance, WPO 비활성화 거리
  • ④ Global Clip Plane추가 컬링 평면 (워터 리플렉션 등)
  • ⑤ HZB이전/현재 프레임 HZB로 오클루전 판정

결과 분기

  • VisibleRoot Node를 Node 큐에 등록
  • Occluded (Main)OccludedInstances 버퍼에 저장
  • Post PassFrustum/ClipPlane 테스트 생략 (Main에서 통과)

Wave Intrinsic(WaveInterlockedAddScalar)으로 64개 스레드가 Atomic 1회로 각자의 쓰기 오프셋을 받는다. 전역 Atomic 없이 큐에 동시 기록.

2단계 — Persistent Thread BVH 순회 (NodeAndClusterCull)

인스턴스가 큐에 등록되면 Persistent Thread 방식으로 BVH를 순회한다. "Persistent Thread"란 GPU Workgroup이 한 번 실행되고 끝나는 게 아니라, 큐가 빌 때까지 계속 작업을 꺼내 처리하는 방식이다. DX12 기준 1,440개 Workgroup이 스레드 풀처럼 동작한다.

각 Workgroup은 매 루프마다 Node 큐Cluster 큐를 확인해 남은 작업을 처리한다.

코드 위치로 보면 이 단계는 InitViews 안에 숨어 있는 것이 아니라 NaniteCullRaster.cppAddPass_InstanceHierarchyAndClusterCullAddPass_NodeAndClusterCull에 있다. 그리고 이 함수는 CullRasterize 내부에서 MainPass용 한 번, 현재 프레임 HZB를 만든 뒤 PostPass용 한 번 더 호출된다. 즉 "2단계"는 설명용 번호일 뿐, 실제 엔진 구조에서는 Nanite::CullRasterize의 컬링 코어다.

이 위치여야 하는 이유도 명확하다. Node/ClusterCull은 단순히 가시성만 고르는 게 아니라 VisibleClustersSWHW, OccludedInstances, Main/PostPassRasterizeArgsSWHW 같은 바로 다음 래스터 단계가 소비할 GPU 버퍼를 직접 채운다. 또한 Post Pass에서는 Main Pass가 만든 HZB와 Main Pass 클러스터 수를 기준으로 ADD_CLUSTER_OFFSET을 적용해 같은 Visibility Buffer / VisibleClusters 버퍼 뒤쪽에 이어 붙여야 한다. 그래서 이 단계는 준비 단계가 아니라, 래스터라이즈와 한 몸으로 붙은 DrawGeometry 내부에 있어야 한다.

ProcessNodeBatch — BVH 노드 처리

Workgroup(64 스레드) 중 앞 16개가 노드 16개를 로드한다. 각 노드는 최대 4개의 자식을 가지므로 16×4 = 64개 스레드 각각이 자식 1개의 가시성을 판단한다. 판단 기준은 다음과 같다.

  • Frustum — 자식 노드의 바운딩 스피어가 뷰 프러스텀 안에 있는가
  • LOD — 화면상 픽셀 밀도 기준으로 이 노드의 LODError가 1픽셀 미만인가. 미만이면 더 내려갈 필요가 없다
  • HZB — 노드의 바운딩 볼륨이 HZB에서 완전히 가려지는가
  • Distance — 뷰 거리 초과 여부

테스트 결과에 따라 자식을 Node 큐(더 내려갈 비리프 노드), Cluster 큐(리프 = 실제 클러스터), 또는 Post Pass 큐(Main Pass Occluded)에 등록한다.

ProcessClusterBatch — 클러스터 최종 판정

Cluster 큐에서 꺼낸 클러스터마다 최종 테스트를 수행한다. LOD·HZB·Distance 통과 시 VisibleClustersSWHW 버퍼에 등록한다. 이때 클러스터 크기에 따라 SW 경로 또는 HW 경로가 결정된다.

// SW 클러스터 → 버퍼 앞에서 채움
SW: offset 0 → 1 → 2 ...

// HW 클러스터 → 버퍼 뒤에서 채움
HW: offset MaxVisible-1 → MaxVisible-2 ...

SW와 HW를 같은 버퍼에 앞뒤로 채우는 이유는 크기를 미리 알 수 없기 때문이다. 양 끝에서 채우면 절대로 겹치지 않는다.

HZB 오클루전 테스트 상세

HZB(Hierarchical Z-Buffer)는 Depth 버퍼의 Mip Chain이다. Mip 레벨 0은 원본 해상도, 레벨이 높아질수록 더 넓은 영역의 최솟값(가장 먼 Depth)을 저장한다.

클러스터의 바운딩 볼륨을 화면에 투영해 픽셀 범위(Rect)를 구한다. 이 Rect를 덮기에 적당한 HZB Mip 레벨을 선택하고, 해당 레벨에서 최솟값을 샘플링한다. 클러스터의 가장 가까운 Depth가 HZB 샘플 최솟값보다 멀면 완전히 가려진 것이므로 Occluded 처리한다.

Main Pass HZB

  • 소스이전 프레임 최종 Depth
  • 정확도카메라 이동 시 부정확 가능
  • 실패 처리OccludedInstances에 저장 → Post Pass

Post Pass HZB

  • 소스현재 프레임 Main Pass 결과
  • 정확도현재 시점 기준으로 정확
  • 실패 처리다음 프레임에서 보정

CalculateSafeRasterizerArgs — 버퍼 오버플로 방지

컬링이 끝나면 SW·HW 각각 몇 개의 클러스터가 살아남았는지 집계한다. 만약 SW 앞에서 채운 것과 HW 뒤에서 채운 것의 합이 버퍼 최대치를 초과하면 래스터라이즈 Dispatch가 버퍼를 넘어 잘못된 메모리를 읽는 문제가 생긴다.

CalculateSafeRasterizerArgs는 이 값을 Clamp해서 안전한 Indirect DrawCall 인자를 만든다. 씬이 복잡하거나 컬링 정확도가 일시적으로 낮아질 때 이 안전망이 동작한다.

컬링 결과물 — VisibleClustersSWHW

이 모든 과정의 최종 산출물은 VisibleClustersSWHW 버퍼다. 이번 프레임에 실제로 그려야 할 클러스터 목록이다. 래스터라이저는 이 버퍼를 순회하며 Visibility Buffer를 채운다. 앞서 Visibility Buffer 픽셀의 VisibleClusterIndex가 클러스터 직접 ID가 아닌 이 버퍼의 인덱스인 이유다.

Visibility Buffer — 클러스터가 픽셀이 되는 과정

Visibility Buffer — 클러스터가 픽셀이 되는 과정

클러스터 계층과 BVH가 준비되면, 런타임에 GPU는 매 프레임 이 클러스터들을 화면에 래스터라이즈한다. 이때 결과물이 기존의 GBuffer가 아닌 Visibility Buffer다. Visibility Buffer가 무엇인지, 클러스터에서 어떻게 만들어지는지를 소스 코드 기준으로 살펴본다.

Visibility Buffer가 뭔가 — 픽셀에 색이 아닌 "주소"를 저장한다

일반적인 Deferred 렌더링은 래스터라이즈를 하면서 GBuffer에 BaseColor, Normal, Roughness 등을 바로 기록한다. Visibility Buffer는 전혀 다르다. 픽셀마다 색상이 아니라 "이 픽셀에 어떤 클러스터의 어떤 삼각형이 있는가"라는 주소만 기록한다.

포맷은 픽셀당 64비트 unsigned integer(R64_UINT 또는 R32G32_UINT 폴백)다. 이 64비트가 다음과 같이 나뉜다.

Visibility Buffer 픽셀 1개의 64비트 구성
상위 32비트 = Depth (IEEE float → uint 변환)
하위 32비트 = VisibleClusterIndex (24비트) | TriangleIndex (7비트) | Reserved (1비트)
VisibleClusterIndex — 클러스터 자체의 ID가 아니라 이번 프레임에 살아남은 클러스터 목록(VisibleClustersSWHW 버퍼)의 인덱스. +1 오프셋으로 0을 "빈 픽셀" 신호로 예약.
TriangleIndex — 해당 클러스터 내 삼각형 번호 (0~127). 7비트로 128개 표현.
Depth — DeviceZ를 float 비트 그대로 uint에 저장. IEEE 754 특성상 양수 float는 uint 비교와 대소가 일치하므로 Depth Test를 정수 비교로 처리 가능.

프레임 시작 — 초기화

매 프레임 시작 시 FRasterClearCS Compute Shader가 Visibility Buffer 전체를 0으로 초기화한다. 0은 VisibleClusterIndex = 0xFFFFFFFF(언패킹 시 −1, 즉 "빈 픽셀")를 의미한다.

OutVisBuffer64[PixelPos] = PackUlongType(uint2(0u, 0u)); // 빈 픽셀로 초기화

Main Pass / Post Pass도 같은 Visibility Buffer를 쓴다

Two-Pass Occlusion Culling에서 중요한 점은 Main Pass용 Visibility Buffer와 Post Pass용 Visibility Buffer가 따로 있는 것이 아니라는 것이다. CullRasterize는 프레임 시작에 InitRasterContext로 VisBuffer를 한 번만 만들고 클리어한 뒤, Main Pass 래스터라이즈가 먼저 값을 쓴다.

그 다음 엔진은 Main Pass 결과로 현재 프레임 HZB를 빌드하고, Post Pass에서 OccludedInstances만 다시 컬링한다. 여기서 살아남은 클러스터는 같은 VisibleClustersSWHW와 같은 VisBuffer64추가로 기록된다. Post Pass가 별도 버퍼를 만들지 않아도 되는 이유는 픽셀 기록 자체가 InterlockedMax 기반이라, Main Pass가 먼저 쓴 픽셀과 Post Pass가 나중에 쓴 픽셀이 경쟁하더라도 항상 더 가까운 값만 남기 때문이다.

소스에서도 이 의도가 드러난다. Main Pass는 기본 오프셋으로 클러스터를 쓰고, Post Pass는 NANITE_RENDER_FLAG_ADD_CLUSTER_OFFSET를 켜서 Main Pass 뒤쪽 인덱스 영역을 사용한다. 즉 픽셀 저장소는 하나, 클러스터 목록은 Main 뒤에 Post를 이어 붙이는 구조다. 그래서 Visibility Buffer 섹션에서 말한 VisibleClusterIndex는 "이번 프레임 전체(Main+Post)를 통과한 클러스터 목록"에 대한 인덱스라고 보는 편이 정확하다.

SW 래스터라이저 — Compute Shader로 직접 픽셀을 쓴다

작은 삼각형(픽셀 수 기준으로 일정 임계 이하)은 Compute Shader가 직접 처리한다. WorkGroup 하나가 클러스터 하나를 맡아, 클러스터 내 각 삼각형을 순회하며 커버하는 픽셀에 값을 쓴다.

핵심은 쓰는 방법이다. 여러 삼각형이 같은 픽셀을 동시에 덮으려 경쟁할 수 있다. Nanite는 이를 InterlockedMax(원자적 최대값 연산) 하나로 해결한다.

uint PixelValue = (VisibleClusterIndex + 1) << 7 | TriIndex;
uint64 WriteValue = PackUlongType(uint2(PixelValue, DepthInt));

ImageInterlockedMaxUInt64(OutVisBuffer64, PixelPos, WriteValue);

64비트 값의 상위 32비트가 Depth이므로, InterlockedMax는 자연스럽게 더 가까운(Depth 값이 큰) 픽셀을 선택한다. Depth Test와 값 기록이 원자적으로 한 번에 이루어지는 것이다. 락 없이 수천 개의 GPU 스레드가 동일 픽셀에 동시에 쓰더라도 항상 가장 가까운 삼각형이 남는다.

HW 래스터라이저 — 큰 삼각형은 하드웨어에게

충분히 큰 삼각형은 전통적인 VS→PS 파이프라인으로 처리한다. VS가 클러스터 데이터에서 버텍스를 읽어 Clip Space로 변환하고, PS에서 SW 래스터와 동일하게 InterlockedMax로 Visibility Buffer에 기록한다. 포맷과 원자 연산 방식은 SW 래스터와 완전히 동일하다.

SW와 HW 래스터라이저가 같은 Visibility Buffer에 동시에 쓸 수 있는 것도 InterlockedMax 덕분이다. 어느 쪽이 먼저 쓰든 결과적으로 가장 가까운 값이 남는다.

래스터라이즈 이후 — VisibleClusterIndex로 실제 클러스터를 찾는다

Visibility Buffer에 저장된 VisibleClusterIndex는 클러스터의 직접 ID가 아니다. 이번 프레임 컬링을 통과한 클러스터들이 담긴 VisibleClustersSWHW 버퍼의 인덱스다. 씬에 수백만 개의 클러스터가 있어도 프레임당 실제로 보이는 건 일부이므로, 이 목록은 훨씬 작다.

// EmitSceneDepthPS — Visibility Buffer 를 읽어 실제 데이터로 변환
UnpackVisPixel(VisPixel, DepthInt, VisibleClusterIndex, TriIndex);

FVisibleCluster VisCluster = GetVisibleCluster(VisibleClusterIndex);
// → VisCluster.PageIndex, VisCluster.ClusterIndex 획득

FCluster Cluster = GetCluster(VisCluster.PageIndex, VisCluster.ClusterIndex);
// → 실제 클러스터 데이터(버텍스·삼각형·머티리얼) 로드

uint ShadingBin = GetMaterialShadingBin(Cluster, PrimitiveId, TriIndex);
// → 이 픽셀에 어떤 머티리얼을 써야 하는지 결정

이 단계(EmitSceneDepthPS)에서 비로소 SceneDepth, ShadingMask, MaterialDepth 버퍼가 생성된다. 이후 BasePass에서 이 정보를 이용해 머티리얼별로 GBuffer를 채운다.

Visibility Buffer 방식의 핵심 이점

픽셀당 저장 크기가 8바이트(64비트)다. 기존 Deferred GBuffer(BaseColor·Normal·Roughness·Depth 등)는 픽셀당 32~128바이트에 달한다. Visibility Buffer는 색상 데이터가 없으므로 래스터라이즈 단계의 메모리 대역폭이 대폭 줄어든다. 또한 픽셀보다 작은 삼각형이 아무리 많아도 실제로 화면에 보이는 픽셀에 대해서만 머티리얼이 실행되므로, 마이크로폴리곤 환경에서 셰이딩 낭비가 없다.

Base Pass 렌더링 — 이제 실제 머티리얼을 칠한다

Base Pass 렌더링 — 이제 실제 머티리얼을 칠한다

Visibility Buffer가 완성되면 "어느 픽셀에 어떤 클러스터의 어떤 삼각형이 있는가"는 이미 정해졌다. 하지만 아직 색은 없다. 이제 해야 할 일은 같은 머티리얼끼리 픽셀을 묶어 실제 BaseColor · Normal · Roughness 등을 계산하는 것이다. Nanite는 이때 래스터 단계용 그룹인 Raster Bin과 셰이딩 단계용 그룹인 Shader Bin(=Shading Bin)을 따로 사용한다.

Bin이란 무엇인가 — 같은 작업을 하는 것끼리 묶은 실행 단위

여기서 Bin은 "같은 렌더링 상태를 공유하는 작업 묶음" 정도로 이해하면 된다. GPU는 완전히 랜덤한 머티리얼/셰이더를 픽셀마다 뒤섞어 처리할 때보다, 같은 파이프라인 상태를 쓰는 삼각형이나 타일을 한 덩어리로 모아 처리할 때 훨씬 효율적이다.

Nanite는 이 묶음을 두 번 만든다. 첫 번째는 래스터라이즈할 때 어떤 머티리얼 경로를 써야 하는가를 기준으로 묶는 Raster Bin이고, 두 번째는 BasePass에서 어떤 픽셀 셰이더/머티리얼 셋업을 실행해야 하는가를 기준으로 묶는 Shader Bin이다.

Raster Bin

  • 기준래스터 단계 머티리얼 ID
  • 목적SW/HW 래스터 실행 묶음
  • 산출물RasterBinMeta / RasterBinArgs / RasterBinData
  • 비용 민감도래스터화 비용 · draw dispatch 수

Shader Bin

  • 기준셰이딩 단계 머티리얼 ID
  • 목적BasePass 픽셀 셰이더 실행 묶음
  • 산출물ShadingMask / MaterialTileRemap / MaterialIndirectArgs
  • 비용 민감도픽셀 셰이더 비용 · material pass 수

왜 Raster Bin이 필요한가

Nanite의 래스터라이저는 단순히 "클러스터를 하나씩" 그리는 것이 아니라, 같은 래스터 머티리얼 경로를 쓰는 삼각형 범위끼리 묶어서 SW/HW 래스터 패스를 발행한다. 그 이유는 같은 bin 안의 작업은 동일한 셰이더 permutation, 동일한 programmable raster 조건, 동일한 머티리얼 테이블 해석 규칙을 공유하기 때문이다.

이렇게 묶어두면 각 래스터 패스는 "이 bin에 속한 클러스터/삼각형만 순회"하면 된다. GPU 입장에서는 파이프라인 상태를 덜 바꾸고, 간접 인수(Indirect Args)도 bin 단위로 한 번 계산하면 되며, 동일한 경로를 타는 삼각형을 연속으로 처리하므로 캐시 지역성과 wave 효율이 좋아진다. 그래서 Nanite는 AddPass_Binning으로 먼저 RasterBinMeta, RasterBinArgsSWHW, RasterBinData를 만든 뒤 래스터라이즈를 시작한다.

Masked 머티리얼이 Raster Bin에 왜 부담이 되나

불투명 Default 경로만 쓰는 bin은 매우 싸다. Visibility Buffer에는 결국 Depth | ClusterIndex | TriangleIndex만 쓰면 되기 때문이다. 하지만 Masked, PDO, WPO 같은 programmable raster가 섞이면 상황이 달라진다. 이 경우 래스터 단계에서도 실제 머티리얼 평가가 필요하고, 픽셀별 clip/depth 수정 여부를 봐야 하므로 "그냥 주소만 쓰는" 경량 경로를 쓸 수 없다.

즉 Masked 머티리얼이 많아질수록 Raster Bin이 늘어날 뿐 아니라, 각 bin이 더 비싼 래스터 경로를 타게 된다. 특히 잎사귀, 펜스처럼 알파 테스트가 많은 콘텐츠는 Nanite의 Visibility Buffer 장점을 일부 상쇄할 수 있다.

Raster Bin이 많아지면 왜 GPU 래스터화 비용이 증가하나

Raster Bin 수가 많다는 것은 래스터라이즈 전에 작업을 더 잘게 쪼갰다는 뜻이다. 그러면 bin별 메타데이터 생성, bin별 Indirect Args 계산, bin별 SW/HW 래스터 디스패치가 모두 늘어난다. 같은 총 삼각형 수라도 bin이 많아지면 dispatch가 잘게 분산되고, 각 bin의 작업량이 작아져 GPU occupancy가 떨어지며, 파이프라인 전환과 인자 준비 오버헤드 비중이 커진다.

쉽게 말해 "한 번에 크게 처리할 수 있던 래스터 작업"이 "잘게 쪼개진 여러 번의 래스터 작업"으로 바뀌는 것이다. 그래서 Raster Bin 증가는 곧 draw/dispatch 수 증가 + 래스터화 전처리 증가 + GPU 래스터화 비용 증가로 이어진다.

Shader Bin은 무엇이고 Raster Bin과 어떻게 다른가

Shader Bin은 래스터용 분류가 아니라 셰이딩용 분류다. Visibility Buffer를 읽어 EmitSceneDepthPS가 각 픽셀의 ShadingBin을 구하고, 이후 ShadeBinning이 화면을 64×64 타일로 나눠 "이 타일에는 어떤 Shader Bin이 존재하는가"를 기록한다.

이후 BasePass는 머티리얼별로 64×64 타일 quad를 그린다. VS는 타일 quad를 만들고, Pixel Shader는 DepthEqual + MaterialDepth로 자기 머티리얼 픽셀만 통과시켜 GBuffer를 채운다. 즉 Raster Bin이 "어떻게 Visibility Buffer를 만들까"의 묶음이라면, Shader Bin은 "어떻게 실제 픽셀 셰이더를 실행할까"의 묶음이다.

Shader Bin이 많아지면 왜 픽셀 셰이더 비용이 증가하나

Shader Bin 수가 많아질수록 화면 타일마다 더 많은 머티리얼 패스가 필요해진다. 그러면 MaterialIndirectArgs 인스턴스 수가 늘고, BasePass에서 머티리얼별 Indirect DrawCall이 증가하며, 같은 타일을 여러 머티리얼이 반복해서 스캔하게 된다.

이 비용은 래스터화보다 픽셀 셰이더 쪽으로 나타난다. 같은 화면을 여러 머티리얼이 분할 점유하면 GBuffer를 채우기 위한 패스 수가 늘고, CPU에서도 FNaniteMaterialPassCommand 빌딩 수가 증가한다. 그래서 글 후반의 프로파일링에서 BasePass가 병목으로 크게 보이는 것이다.

최적화 관점 핵심

Raster Bin이 많아지면 GPU 래스터화 비용이 증가하고, Shader Bin이 많아지면 픽셀 셰이더/BasePass 비용이 증가한다. 둘은 같은 "머티리얼 다양성"에서 나오지만 GPU에 주는 압력은 다르다.

결국 Nanite 최적화는 "클러스터 수만 줄이기"가 아니라, 래스터 단계에서 programmable path를 얼마나 줄일지, 셰이딩 단계에서 유니크 머티리얼/타일 분화를 얼마나 줄일지를 균형 있게 보는 문제다. foliage처럼 Masked가 많은 콘텐츠는 Raster Bin이, modular asset처럼 머티리얼 인스턴스가 과도한 콘텐츠는 Shader Bin이 먼저 병목이 되기 쉽다.

패스 흐름

GPU · CPU 패스 흐름

Nanite는 크게 컬링 + 래스터라이즈 (DrawGeometry), Depth 추출 (EmitDepthTargets), 머티리얼 셰이딩 (BasePass) 세 단계로 구분된다. GPU와 CPU 각각의 실행 순서를 확인해보자.

🖥 GPU 실행 순서

G1
InitArgs

QueueState · ArgBuffer 초기화

G2
MainPass — InstanceCull

이전 프레임 HZB로 인스턴스 컬링

G3
MainPass — NodeClusterCull

Persistent Thread로 BVH 순회 · Cluster 선별

G4
MainPass — Rasterize

RasterBinning → SW/HW 래스터 → VisibilityBuffer

G5
Build HZB (현재 프레임)

MainPass Depth → 현재 프레임 HZB

G6
PostPass — Cull + Rasterize

현재 HZB로 MainPass Occluded 인스턴스 재확인

G7
EmitDepthTargets

VisBuffer → Depth · Stencil · MaterialDepth

G8
ShadeBinning

64×64 타일 → 머티리얼 분류 (3 Compute)

G9
BasePass

머티리얼별 Indirect DrawCall → GBuffer

G10
Readback

스트리밍 피드백 GPU→CPU

⚙️ CPU 실행 순서 (Render Thread)

C1
InitContext

NaniteView · 컬링 버퍼 할당

C2
InitNaniteRaster

FRasterizerPass 생성 · 셰이더 바인딩 (캐싱)

C3
DrawGeometry

컬링·래스터 전체 RDG 커맨드 레코딩

VisBuffer 초기화 → MainPass → HZB 빌드 → PostPass

C4
EmitDepthTargets

Depth 추출 3패스 RDG 등록

C5
ShadeBinning

머티리얼 분류 3 Compute 패스 RDG 등록

C6
BasePass

FNaniteMaterialPassCommand 빌딩 · Indirect DrawCall 등록

CPU 최대 비용 — 머티리얼 수에 비례

C7
Readback · Streaming

스트리밍 요청 처리 (비동기)

💡 RDG 레코딩 vs GPU 실행

CPU의 C3~C6는 실제 렌더링이 아니라 RDG 커맨드 레코딩이다. 실제 GPU 실행은 RDG ExecutePass 시점에 이루어진다. CPU 프로파일링에서 BasePass가 Nanite CPU 총비용의 약 15%를 차지하는 것은, 머티리얼 수만큼 드로콜을 빌딩하는 C6 작업이 무겁기 때문이다.

GPU 비용

GPU 비용

이 표는 Nanite GPU 총비용을 100으로 놓고, 각 패스가 그중 몇 퍼센트를 직접 차지하는지 보여준다. 예를 들어 5%라면 "이 패스 하나가 Nanite GPU 총비용의 약 5%를 차지한다"는 뜻이다.

Pass비중 %설명
Nanite::DrawGeometry~4%컬링+래스터 전체. Nanite GPU 총비용 중 컬링 디스패치 오버헤드 비중
Nanite::VisBuffer~0.1%래스터 페이즈 전체. 실제 비용은 자식 패스
NaniteBasePass~0.2%머티리얼 셰이딩 페이즈 컨테이너
Nanite::BasePass~19%GPU 단일 패스 최대 비용. 머티리얼별 Indirect DrawCall → GBuffer
Nanite::Readback~8%GPU→CPU 스트리밍 피드백. 동기 읽기
Nanite::EmitDepthTargets~5%VisBuffer → Depth·Stencil·MaterialDepth (FullScreen ×3)
Nanite::ShadeBinning~4%머티리얼 타일 분류 3 Compute 패스
Nanite::RasterizeLumenCards~0%Lumen 캡처용 래스터라이즈
Nanite::InitContext~2%View·Buffer 초기화

* 기준: Nanite GPU 총비용 = 100. 표의 비중 값은 각 패스가 직접 차지하는 비용 비율이다.

⚡ GPU 병목 핵심

DrawGeometry Incl(100%)은 자식 패스를 포함한 계층 합산이다. 순수 컬링 오버헤드는 Nanite GPU 총비용의 약 4%로 전체 대비 작다. 실질 GPU 최대 병목은 BasePass 머티리얼 셰이딩으로, Nanite GPU 총비용의 약 19%를 차지하며 씬 내 유니크 Nanite 머티리얼 수에 비례한다.

Bin 관점으로 보면 Raster Bin 수 증가는 RasterBinning과 SW/HW Rasterize 쪽 비용을 밀어 올리고, Shader Bin 수 증가는 ShadeBinning과 BasePass 픽셀 셰이더 비용을 밀어 올린다. 그래서 "삼각형 수를 줄였다"만으로는 설명이 끝나지 않고, 어떤 bin이 늘었는지를 같이 봐야 한다.

CPU 비용

CPU 비용

이 표는 Nanite CPU 총비용을 100으로 놓고, 각 패스가 Render Thread 쪽에서 몇 퍼센트를 직접 차지하는지 보여준다. 예를 들어 1.2%라면 "이 패스 하나가 Nanite CPU 총비용의 약 1.2%를 차지한다"는 뜻이다.

Pass비중 %설명
Nanite::DrawGeometry~3%컬링 전체 RDG 레코딩 루트
Nanite::VisBuffer~0.2%VisBuffer 페이즈 루트
NaniteBasePass~0.4%머티리얼 셰이딩 페이즈 루트
Nanite::BasePass~15%CPU 최대 비용. FNaniteMaterialPassCommand 빌딩 + Indirect DrawCall 등록
InitNaniteRaster~0.1%FRasterizerPass 생성 (캐싱됨)
Nanite::RasterizeLumenCards~0.1%Lumen 래스터 레코딩
FVirtualShadowMapArray::RenderVSMNanite~0%VSM Nanite 렌더링 레코딩
Nanite::LumenMeshCapturePass~0.6%Lumen 카드 캡처 레코딩
FPrimitiveSceneInfo_CacheNaniteMaterialBins~0.2%씬 변경 시 MaterialBin 캐싱
Nanite::ShadeBinning~1.3%머티리얼 분류 Compute 레코딩
Nanite::EmitDepthTargets~1.2%Depth 추출 패스 레코딩
Nanite::InitContext~0.7%버퍼 할당·View 초기화
NaniteMaterialListApply~0.6%씬 변경 시 머티리얼 목록 적용

* 기준: Nanite CPU 총비용 = 100. 표의 비중 값은 각 패스가 직접 차지하는 비용 비율이다.

⚡ CPU 병목 핵심

Nanite::BasePassNanite CPU 총비용의 약 15%를 차지하는 최대 비용 패스다. 씬의 유니크 Nanite 머티리얼 수에 비례하므로, 머티리얼 인스턴스를 과도하게 세분화하면 GPU·CPU 모두 선형 증가한다. 씬 변경 시에는 CacheNaniteMaterialBins도 추가로 발생한다.

CPU 쪽에서도 본질은 비슷하다. Shader Bin과 유니크 머티리얼 수가 늘수록 FNaniteMaterialPassCommand 빌딩과 indirect draw 등록 수가 같이 늘고, 준비 단계의 CacheNaniteMaterialBins도 변경 시점마다 다시 계산해야 한다. 즉 BasePass 병목은 GPU만의 문제가 아니라 명령 준비 비용까지 함께 끌고 온다.

패스별 상세

패스별 상세

VisibilityBuffer 초기화 — InitRasterContext

래스터라이즈 시작 전 VisibilityBuffer를 생성하고 클리어한다

Nanite 래스터라이즈의 결과물인 VisibilityBuffer는 매 프레임 InitRasterContext에서 생성·초기화된다.

포맷 결정

플랫폼이 64bit uint UAV를 지원하면 PF_R64_UINT를 사용한다. 지원하지 않으면 PF_R32G32_UINT로 폴백한다. 픽셀 하나에 다음이 패킹된다.

uint64 = [ Depth(32bit) | ClusterIndex+1(22bit) | TriangleIndex(7bit) | Reserved(3bit) ]

Depth를 최상위 비트에 배치해, InterlockedMax 한 번으로 Depth Test와 ClusterIndex/TriangleIndex 기록을 동시에 처리한다. ClusterIndex는 "0 = 비어있음"을 구분하기 위해 +1 오프셋을 사용한다.

DepthBuffer 생성

VisibilityBuffer와 함께 동일 해상도의 DepthBuffer(PF_DepthStencil)를 생성한다. SW 래스터라이저는 VisibilityBuffer의 uint64에 Depth를 내장하고, HW 래스터라이저는 하드웨어 DepthBuffer를 함께 사용한다.

RasterClear — VisibilityBuffer 초기화

AddClearVisBufferPass에서 Compute Shader(FRasterClearCS)를 실행해 VisibilityBuffer를 0으로 초기화한다. 0은 "아무 클러스터도 래스터라이즈되지 않음"을 의미한다.

PF_R64_UINT InterlockedMax RasterClear CS
Main Pass vs Post Pass — Two-Pass Occlusion Culling 개요

이전 프레임 HZB로 빠르게 컬링하고, 현재 프레임으로 누락을 보정한다

카메라가 움직이거나 오브젝트가 등장하면, 이전 프레임 기준으로 컬링했을 때 실제로는 보여야 하는 오브젝트가 Occluded로 처리될 수 있다. Nanite는 이를 두 번의 패스로 해결한다.

Main Pass

이전 프레임 HZB를 기준으로 인스턴스·노드·클러스터를 컬링한다. 컬링에서 살아남은 클러스터를 SW/HW 래스터라이즈해 VisibilityBuffer를 채운다.

컬링 중 Occluded로 판정된 인스턴스는 OccludedInstances 버퍼에 기록해 둔다.

HZB 빌드

MainPass 래스터라이즈 결과로 생성된 DepthBuffer를 사용해 현재 프레임 HZB를 빌드한다. 이 HZB는 PostPass 컬링에서만 사용된다.

Post Pass

MainPass에서 Occluded로 기록된 인스턴스들을 대상으로 현재 프레임 HZB로 다시 컬링 테스트한다. 현재 시점에서 실제로 보이는 인스턴스가 있다면 래스터라이즈해 VisibilityBuffer에 추가한다.

PostPass에서는 MainPass 결과를 유지한 채 새 클러스터를 VisibilityBuffer에 추가한다. PostPass의 VisibleClustersSWHW는 MainPass 데이터 뒤에 이어 붙여진다.

연속 프레임 가정

이 방식은 "프레임 간 시점 변화가 크지 않다"는 가정에 기반한다. 빠른 카메라 이동이나 급격한 오브젝트 변화 시 PostPass에서도 누락이 발생할 수 있으며, 이는 다음 프레임에서 보정된다.

이전 HZB → MainPass 현재 HZB 빌드 현재 HZB → PostPass
TwoPassOcclusionCulling 상세 — 컬링 내부 동작

InstanceCulling · Persistent Thread NodeClusterCull · CalculateSafeArgs

주요 버퍼 구조

컬링 전 단계에서 여러 공유 버퍼가 준비된다.

QueueState — Node·Cluster 큐의 읽기/쓰기 Offset과 총 개수를 추적. Main/Post 패스 각각 인덱스 0, 1번 슬롯 사용.
MainAndPostNodesAndClusterBatches — Node 정보와 ClusterBatch 개수를 저장하는 공유 버퍼. Main/Post 패스가 동일 버퍼를 구역을 나눠 사용.
MainAndPostCandidateClusters — 살아남은 Cluster를 FVisibleCluster로 패킹해 저장. MainPass는 앞에서, PostPass는 뒤에서 역방향으로 기록.
VisibleClustersSWHW — 최종 가시 Cluster. SW는 앞부터, HW는 뒤부터 기록.

InstanceCulling CS — 인스턴스 단위 컬링

WorkGroup당 64개 thread, 각 thread가 인스턴스 1개를 담당한다.

PrimitiveID·InstanceData 로드 → ② Distance / GlobalClipPlane / Frustum / HZB 컬링 순서로 테스트 → ③ Visible이면 Root Node를 QueueState에 기록 → ④ Occluded면 OccludedInstances에 기록(PostPass 대기).

Wave Intrinsic(WaveInterlockedAddScalar_)을 사용해 64개 thread가 Atomic 연산 1회로 각자의 NodeWriteOffset을 받는다. 이는 Nanite 셰이더 전체에서 반복되는 최적화 패턴이다.

NodeAndClusterCull — Persistent Thread 계층 순회

DX12 기준 1,440개 WorkGroup이 thread pool처럼 동작한다. 각 WorkGroup은 Node 큐와 Cluster 큐가 모두 빌 때까지 루프를 반복한다.

ProcessNodeBatch

WorkGroup(64 thread) 중 앞 16개가 MainAndPostNodesAndClusterBatches에서 Node 16개를 로드한다. 각 Node는 최대 4개의 Child를 가지므로, 16 × 4 = 64개 thread가 각자 Child 1개의 가시성을 판단한다.

· Child가 Leaf가 아니고 Visible: Child Node를 QueueState에 등록 (다음 루프에서 처리).
· Child가 Leaf이고 Visible: Cluster를 CandidateClusters에 등록 + ClusterBatch에 64개 묶음 정보 기록.
· Child가 Occluded (MainPass): 현재 Node를 PostPass 큐에 등록해 재검사 예약.

ProcessClusterBatch

WorkGroup(64 thread) 각각이 Cluster 1개를 담당. Distance / GlobalClipPlane / HZB 컬링 후, SmallEnoughToDraw로 SW/HW 래스터라이즈 방식을 결정. Visible이면 VisibleClustersSWHW에 기록. MainPass에서 Occluded된 클러스터는 PostPass CandidateClusters에 역방향으로 추가.

CalculateSafeRasterizerArgs

컬링 결과(SW/HW 클러스터 수)를 기반으로 래스터라이즈 Indirect DrawCall argument를 생성한다. 최대 허용 클러스터 수를 초과하지 않도록 Clamp 처리 후 SafeRasterizeArgsSWHW에 기록한다.

SW: { NumClustersSW/64, 1, 1 } — Compute Dispatch 인수.
HW: { 128×3, NumClustersHW, 0, 0 } — DrawInstanced 인수 (인스턴스=Cluster, 버텍스=최대 삼각형×3).

Wave Intrinsic Persistent Thread Node BVH 순회 Indirect Args
RasterBinning — 래스터라이즈 전처리

가시 Cluster와 머티리얼을 연결해 SW/HW 래스터 Indirect Arg를 만든다

앞 섹션에서 설명한 것처럼 Raster Bin은 "같은 래스터 경로를 공유하는 작업 묶음"이다. AddPass_Binning은 컬링 결과인 VisibleClustersSWHW를 바로 그리지 않고, 먼저 이를 RasterBin별 실행 리스트로 재정렬한다. 이렇게 해야 이후 SW/HW 래스터 패스가 bin 단위로 Indirect Dispatch/Draw를 발행할 수 있다.

RasterBinCount

각 가시 클러스터에서 사용하는 래스터 머티리얼 정보를 읽어 RasterBinMeta[RasterBin].BinSWCount, BinHWCount를 증가시킨다. 클러스터 내 삼각형이 최대 3개 머티리얼을 사용하면 FastPath, 그 이상이면 Page에서 직접 머티리얼 테이블을 로드한다.

Indirect Dispatch 인수는 클러스터 수 / 64 (WorkGroup당 64 thread, thread당 Cluster 1개).

RasterBinReserve

각 RasterBin이 RasterBinData 버퍼에서 차지할 오프셋을 계산한다. RangeAllocator(uint32 atomic counter)에서 SW+HW 클러스터 총합만큼 영역을 할당받아 RasterBinMeta[RasterBin].ClusterOffset에 저장한다. 또한 각 RasterBin의 Indirect Draw/Dispatch 인자 초기값을 RasterBinArgsSWHW에 기록한다.

RasterBinScatter

RasterBinCount와 동일 로직으로 클러스터를 다시 순회하되, 이번에는 RasterBinData[ClusterOffset]ClusterIndex + 클러스터 내 삼각형 범위(StartTri, NumTri)를 기록한다. 동시에 RasterBinArgsSWHW의 해당 머티리얼 슬롯 클러스터 카운트를 1 증가시킨다.

완료 후 각 RasterBin은 "어떤 클러스터를 어떤 삼각형 범위로 그릴지"를 정확히 알게 된다. 이후 SW/HW 래스터라이즈는 이 리스트를 그대로 소비한다. 즉 RasterBinning은 개념적으로는 분류 단계이고, 구현상으로는 bin별 실행 커맨드 버퍼를 만드는 단계다.

RasterBinMeta RasterBinData Indirect Arg 생성 Wave Intrinsic
SW Rasterize — MicropolyRasterize

작은 삼각형을 Compute Shader로 직접 래스터라이즈한다

픽셀보다 작은 삼각형을 하드웨어 래스터라이저에 넘기면, PixelShader는 실제 Active lane 1개에 Helper lane 3개가 붙어 최대 4배 연산 낭비가 발생한다. SW 래스터라이저는 이를 Compute Shader로 직접 처리해 낭비를 제거한다.

MicropolyRasterize CS

RasterBin별로 Indirect Dispatch가 발행된다. WorkGroup당 32개 thread(NANITE_VERT_REUSE_BATCH 활성 시), 각 WorkGroup이 Cluster 1개를 담당한다.

TSlidingWindowVertexCache: 32개 thread가 각자 삼각형 1개를 담당하면서, 자신의 삼각형에 필요한 버텍스가 이웃 thread(WindowSize=32 이내)에서 이미 변환되었다면 재사용한다. 버텍스 변환 중복 계산을 줄이기 위한 Compute Shader 고유 최적화다.

RasterizeTri_Adaptive

삼각형 크기에 따라 두 방식 중 하나를 선택한다.

· Rect 방식 (작은 삼각형): MinPixel~MaxPixel을 단순 반복하며 각 픽셀에 WritePixel 호출.
· Scanline 방식 (큰 삼각형): 스캔라인 단위로 범위를 계산해 래스터라이즈.

WritePixel — VisibilityBuffer 기록

픽셀 좌표·PixelValue·Depth를 조합해 InterlockedMax(VisibilityBuffer, Depth | ClusterIdx+1 | TriIdx)를 호출한다. Depth가 최상위 비트이므로 InterlockedMax 한 번으로 Depth Test와 값 기록이 동시에 이루어진다. Masked 머티리얼의 경우 Clip 연산 후 조건부 기록한다.

Compute Shader TSlidingWindowVertexCache InterlockedMax Quad Overdraw 제거
HW Rasterize — FHWRasterizeVS · PS

충분히 큰 삼각형은 기존 VS·PS 파이프라인으로 래스터라이즈한다

SmallEnoughToDraw 판정에서 SW보다 HW가 효율적이라고 판단된 클러스터는 하드웨어 래스터라이저로 처리한다.

FHWRasterizeVS — 버텍스 셰이더

Indirect DrawInstanced 방식. 인스턴스 = Cluster, 버텍스 개수 = 128×3 = 384 (최대 삼각형 수 × 3).

· SV_InstanceID로 ClusterIndex를 결정.
· SV_VertexID로 현재 삼각형과 버텍스 번호를 결정 (VertexID / 3 = LocalTriIndex, VertexID % 3 = VertexPos).
· 클러스터가 소유한 삼각형 범위를 초과하면 Position = float4(0,0,0,1)로 퇴화(Degenerate)시켜 래스터라이즈를 스킵한다.
· 유효 삼각형은 클러스터 데이터에서 버텍스를 읽어 World → Clip Space로 변환. PixelValue = ClusterIndex+1 | TriangleIndex를 PS로 넘긴다.

FHWRasterizePS — 픽셀 셰이더

VS에서 받은 PixelValueSV_Position.z(DeviceZ)를 조합해 SW 래스터와 동일하게 InterlockedMax(VisibilityBuffer, Depth | PixelValue)를 호출한다.

Masked 머티리얼 또는 PDO(PixelDepthOffset)가 있는 경우 Barycentric을 보간해 머티리얼 셰이더를 실행하고 결과를 VisibilityBuffer에 반영한다.

DrawInstanced Indirect Degenerate Triangle InterlockedMax
HZB 생성 — VisibilityBuffer 기반 Hierarchical Z-Buffer

MainPass 결과로 현재 프레임 HZB를 빌드해 PostPass 컬링에 사용한다

MainPass 래스터라이즈가 완료되면, 생성된 DepthBuffer를 기반으로 BuildPreviousOccluderHZB를 통해 현재 프레임 HZB를 생성한다. HZB는 원본 Depth의 Mip Chain으로 구성된다. Mip 레벨이 높을수록 더 큰 영역의 최소 Depth를 저장해 큰 오브젝트 컬링에 효율적이다.

PostPass 컬링 사용

생성된 현재 프레임 HZB는 CullingParameters에 바인딩되어 PostPass InstanceCulling과 NodeClusterCull에서 사용된다. MainPass에서 Occluded로 기록된 인스턴스를 이 HZB로 다시 검사해 "현재 시점에서도 Occluded인지" 확인한다.

최종 HZB

PostPass까지 완료된 후 BeginOcclusionTests에서 최종 HZB를 빌드한다. 이 HZB가 다음 프레임 MainPass에서 이전 프레임 HZB로 사용된다. 이렇게 프레임마다 HZB가 갱신되며 컬링 정확도가 유지된다.

Depth Mip Chain PostPass 컬링 입력 다음 프레임 MainPass 입력
EmitDepthTargets — VisibilityBuffer에서 Depth 추출

Nanite GPU 총비용의 약 5% · FullScreen Quad 3회

VisibilityBuffer만으로는 기존 렌더링 파이프라인과 연동이 불가능하다. 세 개의 FullScreen Quad 패스로 필요한 버퍼를 추출한다.

EmitSceneDepthPS

VisibilityBuffer에서 Depth·ClusterIndex·TriangleIndex를 복원한다. ClusterIndex·TriangleIndex로 FCluster 정보를 읽어 Velocity와 ShadingMask를 출력한다.

ShadingMask = { NanitePixel 여부, ShadingBin(=LegacyShadingId), 라이팅 채널, DecalReceiver }. ShadingBin은 이후 EmitMaterialDepthPS와 ShadeBinning·BasePass에서 반복 사용된다.

EmitSceneStencilPS

ShadingMask에서 bIsDecalReceiver가 설정된 픽셀을 찾아 Stencil에 132(0x84)를 기록한다. Decal 렌더링에서 해당 픽셀만 처리할 수 있도록 마스킹하는 용도다.

EmitMaterialDepthPS

ShadingMask에서 ShadingBin을 읽어 MaterialDepthTable에서 MaterialDepthId를 조회한다. 이 값을 Depth Buffer에 float으로 출력해 MaterialDepth 버퍼를 생성한다.

MaterialDepthId = float(StateBucketId + 1) / NANITE_MAX_STATE_BUCKET_ID. 머티리얼마다 고유한 Depth 값이 부여된다. BasePass에서 DepthEqual 판별의 기준이 된다.

SceneDepth ShadingMask MaterialDepth Stencil
ShadeBinning — 머티리얼 타일 분류

Nanite GPU 총비용의 약 4% · 3 Compute 패스로 머티리얼별 Indirect DrawArg를 준비한다

앞에서 본 Shader Bin은 "같은 픽셀 셰이더/머티리얼 셋업을 공유하는 타일 묶음"이다. BasePass는 머티리얼별로 Indirect DrawCall을 발행하므로, 먼저 어떤 Shader Bin이 어느 64×64 타일에 존재하는지를 알아야 한다. 이 역할이 ShadeBinning이다.

InitializeMaterials

MaterialSlot 개수/64개 WorkGroup을 Dispatch. 각 MaterialSlot의 MaterialIndirectArgs(DrawCall argument)를 초기화하고 MaterialTileRemap 버퍼를 0으로 클리어한다. 이후 단계가 "머티리얼별로 몇 개 타일을 그려야 하는가"를 채워 넣을 빈 그릇을 준비하는 셈이다.

ClassifyMaterials

화면을 64×64 타일로 분할. WorkGroup 1개가 타일 1개를 담당. 16×16 thread가 타일의 64×64 픽셀을 4×4번 반복해 모두 처리한다.

각 픽셀에서 ShadingMask를 읽어 ShadingBin을 추출. groupshared TileMaterialBins(비트마스크)에 해당 ShadingBin 위치에 bit를 set한다. Wave Intrinsic으로 같은 ShadingBin을 사용하는 thread들을 묶어 InterlockedOr 횟수를 최소화한다.

루프 완료 후 MaterialTileRemap[MaterialSlot][TileLinear]를 비트마스크로 기록한다. 즉 "이 Shader Bin은 이 타일을 그려야 한다"는 맵이 만들어진다. 동시에 해당 MaterialSlot의 MaterialIndirectArgs.InstanceCount를 타일 수만큼 증가시킨다.

FinalizeMaterials

Compute Shader 방식(기본 설정에서는 미사용)을 위해 8×8 Micro Tile 기준 DispatchGroup을 계산해 MaterialIndirectArgs에 기록한다.

64×64 Tile MaterialTileRemap 비트마스크 Indirect Args 생성 Wave Intrinsic
BasePass — Material Pass와 GBuffer 생성

Nanite GPU 총비용의 약 19% (최대) · Nanite CPU 총비용의 약 15% (최대)

GPU·CPU 양쪽에서 모두 가장 비싼 패스다. Raster 단계가 "보이는 픽셀의 주소"를 만들었다면, BasePass는 이제 Shader Bin 단위로 실제 머티리얼 셰이더를 실행해 GBuffer를 채운다. 머티리얼 수와 타일 분화가 많을수록 Indirect DrawCall도 늘어난다.

BuildNaniteMaterialPassCommands — CPU 드로콜 빌딩

FScene에 등록된 모든 Nanite 머티리얼(FNaniteMaterialEntry)을 순회해 FNaniteMaterialPassCommand를 생성한다. 즉 CPU가 Shader Bin별 BasePass 실행 명령을 빌드하는 단계다. WPO 사용 여부로 두 개의 패스로 분리된다.

· 패스 1 (EmitGBuffer): WPO 없는 머티리얼. GBuffer에 BaseColor·Normal·Roughness·Metallic 출력.
· 패스 2 (EmitGBufferWithVelocity): WPO 있는 머티리얼. GBuffer 출력 + Velocity Buffer도 함께 출력.

각 Command에는 MaterialDepth 값이 기록된다. 이 값이 VS에서 설정하는 Depth이며 DepthEqual 판별 기준이 된다.

FNaniteIndirectMaterialVS — 타일 Quad 생성

SV_InstanceID는 현재 머티리얼이 그릴 타일 순번(0, 1, 2...)이다. 선형 순번을 실제 타일 인덱스로 변환하기 위해 MaterialTileRemap에서 비트마스크를 읽어 TargetTileCount번째 bit가 1인 위치를 탐색한다. 다시 말해 ShadeBinning이 만든 "이 머티리얼이 처리해야 할 타일 목록"을 실제 드로우로 펼치는 단계다.

타일 인덱스를 UV로 변환하고, UV를 NDC 좌표로 변환해 64×64 Quad를 생성한다. Z 값 = MaterialDepth로 설정해 DepthEqual이 올바르게 동작하도록 한다.

DepthEqual 판별

EmitMaterialDepthPS에서 생성한 MaterialDepth Buffer를 DepthStencil Target으로 바인딩. DepthTest를 Equal로 설정. VS에서 출력한 Z(= MaterialDepth)와 MaterialDepth Buffer의 값이 일치하는 픽셀만 Pixel Shader가 실행된다. 다른 머티리얼 픽셀은 자동으로 기각된다.

BasePassPixelShader — GBuffer 출력

VisibilityBuffer에서 ClusterIndex·TriangleIndex를 읽어 Barycentric 좌표를 보간하고, 머티리얼 셰이더를 실행해 GBuffer에 출력한다. 이 부분은 일반 비-Nanite BasePass 픽셀 셰이더와 동일한 코드를 사용한다.

Indirect DrawCall × 머티리얼 수 DepthEqual 판별 GBuffer WPO Velocity
Readback — 스트리밍 피드백

Nanite GPU 총비용의 약 8% · GPU→CPU 동기 읽기

Nanite는 Virtual Texture와 동일한 방식으로 필요한 클러스터 페이지만 런타임에 스트리밍 로드한다. 래스터라이즈 중 GPU의 RequestPageRange가 현재 화면에 필요하지만 아직 로드되지 않은 클러스터 페이지 요청을 누적한다.

Readback 패스에서 이 요청 버퍼를 CPU로 읽어와 다음 프레임의 클러스터 스트리밍을 스케줄링한다. GPU→CPU 동기 읽기이므로 파이프라인 버블을 유발할 수 있다. 여기의 약 8%는 이 패스가 Nanite GPU 총비용 중 직접 차지하는 비율이며, 그 안에 대기 시간도 포함된다.

Virtual-texture-style Streaming GPU→CPU Readback 비동기 Async Load

전체 흐름 요약

에셋 빌드 시 메시 → Cluster 계층 (최대 128 삼각형). 씬 등록 시 사전 준비 단계에서 RasterBin/ShadingBin과 머티리얼 슬롯을 만든다. 런타임에는 Two-Pass Occlusion Culling으로 가시 Cluster 선별 → RasterBinning으로 Raster Bin별 실행 범위 구성 → SW/HW Rasterize로 VisibilityBuffer 작성 → EmitDepthTargets로 Depth·MaterialDepth 추출 → ShadeBinning으로 Shader Bin별 타일 분류 → BasePass에서 머티리얼별 Indirect DrawCall로 GBuffer 생성. GPU·CPU 최대 병목은 모두 유니크 머티리얼 수에 비례하는 BasePass다.