16

Part V · Pipeline Chapter 16 of 18 Build 2025.31550

Instancing의 응용과 한계

“LOD, frustum culling, sorting을 GPU에 옮기는 것은 결국 dispatch와 prefix sum의 조합이다.”

이 챕터에서 정복할 CG 개념

왜 이게 중요한가

15장의 1024 박스가 100만 개가 되면 draw call 한 번이라도 vertex shader는 mesh의 모든 vertex × 100만 번 돈다. 카메라 뒤의 인스턴스도, 멀리 한 픽셀에 모인 인스턴스도 모두. 16장의 질문: 어느 인스턴스가 화면에 보이는가, 어느 단순도의 mesh로 그릴 것인가, 반투명이면 어느 순서로 그릴 것인가. 답의 공통 인프라는 dispatch + prefix sum이다. POP 측에서는 GLSL POP / Advanced POP이 그 dispatch를 직접 쓸 자리, Sort POP과 Delete POP이 노드 형태의 답이다.

POP에서의 노출 지점

이론

LOD: per-instance distance → mesh index

d_i = length(P_i - C)로 거리를 재고 임계값으로 lod_i ∈ {0,1,2}를 정한다. GLSL MAT 안에서 mesh를 바꾸기 어려우므로 실용적 패턴은: GLSL POP이 lod attribute를 만든다 → lod별로 부분집합을 만들어 (Delete POP 세 번 또는 Advanced POP의 compaction) → 세 개의 Geometry COMP에 각각 다른 단순도의 mesh를 둔다 (geodesic frequency 4 / 2 / 1 식).

Frustum culling: clip-space 검사

clip = P_proj * V * vec4(P_i, 1.0), visible = all(abs(clip.xyz) <= clip.w). clip.w로 나누면 NDC가 되고 [-1,+1] 안이면 frustum 내부 — 위 식은 나눗셈 없이 같은 판정. mesh의 AABB가 있으면 8 코너를 검사해야 정확하다. 인스턴스 단위는 보통 점 한 개 또는 bounding sphere center로 근사한다.

Compaction: prefix sum

visible_i ∈ {0,1} 비트 배열에서 살아남은 인스턴스만 모은 출력 인덱스는 exclusive scan으로 얻는다.

input visible : 1 0 1 1 0 1 0 1
exclusive scan: 0 1 1 2 3 3 4 4
출력 길이 = scan[7] + visible[7] = 4 + 1 = 5

visible_i==1인 자리만 out[scan[i]] = in[i]로 옮기면 압축이 끝난다. prefix sum은 work-efficient 알고리즘 (Hillis-Steele, Blelloch)이 표준이다. Harris/Sengupta/Owens의 GPU Gems 3 Ch.39가 표준 참고.

Sorting: 반투명 인스턴스의 거리순

GPU sort의 두 표준 — Radix sort: 키의 비트 단위로 패스마다 0/1 split + prefix scan. log2(bits) 패스. Sort POP 내부 (wiki: FidelityFX Parallel Sort). Bitonic sort: 비교 네트워크. log²(N) 패스. 투명도 정렬에는 거리 키 32-bit float면 충분하다.

한계

instancing이 줄이는 비용은 draw call이지 vertex 작업도 픽셀 작업도 아니다. 따라서 큰 N에서 병목은 다음으로 옮겨 간다.

per-instance branching의 비용도 크다. SIMT 그룹 안에서 인스턴스마다 다른 분기를 타면 양쪽이 다 실행된다. LOD를 GLSL MAT 안의 if-else 대신 인스턴스 그룹 분리로 처리하는 이유가 이것이다.

손작업 (Hands-on)

시작 파일

15장의 1024-박스 그리드를 이어 쓴다.

단계 1 — per-instance distance와 LOD attribute

Grid POP1과 Geometry COMP 사이에 GLSL POP1을 끼운다. Attribute Class: point. Output Attributes: lod. Create Attribs: lod, int 1컴포넌트. Vectors 페이지에 camPos vec3을 추가하고 Camera COMP의 위치를 expression으로 바인딩한다 (wiki 미확정 — 정확한 expression 형태는 실험 필요).

uniform vec3 camPos;
uniform float dNear;  // 예: 3.0
uniform float dFar;   // 예: 7.0

void main() {
    const uint id = TDIndex();
    if (id >= TDNumElements()) return;

    float d = length(TDIn_P() - camPos);
    int l = (d < dNear) ? 0 : (d < dFar ? 1 : 2);
    lod[id] = l;
}

이 코드가 GPU에서 하는 일: 1024 thread가 각자 한 점의 거리를 재고 두 임계값으로 분류해 lod SSBO에 정수를 쓴다. 같은 워크그룹의 thread가 보통 비슷한 거리이므로 SIMT 발산은 작다.

시각화: Geometry COMP의 Instance 3 페이지에서 instance0customop=glsl_pop1, instance0customx=lod(0)로 lod를 custom attribute 0번에 매핑한다. fragment에서:

vec3 lodCol[3] = vec3[3](
    vec3(1.0, 0.3, 0.3),  // LOD 0
    vec3(0.3, 1.0, 0.3),  // LOD 1
    vec3(0.3, 0.3, 1.0)   // LOD 2
);
int l = clamp(int(TDInstanceCustomAttrib0().x), 0, 2);
fragColor = TDOutputSwizzle(vec4(lodCol[l], 1.0));

보게 될 것: 카메라 가까이 빨강, 중간 초록, 멀리 파랑. 카메라가 움직이면 분류가 갱신된다.

단계 2 — Frustum culling: visibility bit

GLSL POP2 (point class, Output Attributes: visible). Matrices 페이지에 viewProj 4×4 — Camera COMP의 view·projection 합성을 expression으로 (wiki 미확정 — 정확한 멤버명·expression은 실험 필요).

uniform mat4 viewProj;

void main() {
    const uint id = TDIndex();
    if (id >= TDNumElements()) return;

    vec4 clip = viewProj * vec4(TDIn_P(), 1.0);
    bool inside = all(lessThanEqual(abs(clip.xyz), vec3(clip.w)));
    visible[id] = inside ? 1 : 0;
}

이 코드가 GPU에서 하는 일: vertex shader가 gl_Position을 만드는 것과 같은 곱셈을 compute 측에서 한다. clip 좌표가 -w..+w 안이면 frustum 안. 인스턴스가 mesh이면 bounding sphere 반경 보정이 필요하다.

단계 3 — Culling 결과 적용

경로 A — Delete POP: GLSL POP2 → Delete POP. Mode: Attribute, attribute visible, condition == 0. 결과는 보이는 점만 남고, 이 출력을 Geometry COMP의 instanceop에 꽂는다.

경로 B — GLSL Advanced POP: Output 페이지의 pointcountmode=set으로 point count를 동적으로 줄인다. multi-pass — pass 1: visible의 exclusive scan을 prefix attribute에 채움. pass 2: scatter.

// pass 2 (scatter) 개념
void main() {
    const uint id = TDIndex();
    if (id >= TDNumElements()) return;

    uint v = uint(TDInPoint_visible(0, id));
    if (v == 0u) return;

    uint outIdx = uint(TDInPoint_prefix(0, id));
    oTDPoint_P[outIdx] = TDInPoint_P(0, id);
}

실제 scan은 Harris/Sengupta/Owens의 up-sweep / down-sweep 알고리즘 (shared memory)이다. wiki 미확정 — Advanced POP의 Passes / Copy-Previous-Pass-Output-to-Input이 이 패턴에 정확히 매핑되는 방식은 glsl_pop_deep.md §6에 wiki 충돌이 있음. 실험 필요.

보게 될 것: 카메라를 회전시켜 grid가 화면 밖으로 나가면 그 부분의 박스가 사라진다. draw call은 여전히 한 번이지만 vertex 수가 줄어든다.

단계 4 — Sort POP으로 거리순 정렬

GLSL POP3 (Output Attributes: dist)에서 dist[id] = length(TDIn_P() - camPos); 한 줄. 다음에 Sort POP1, attribute dist, pointrev On이면 멀리부터 (back-to-front). 출력을 Geometry COMP의 instanceop에 꽂는다.

보게 될 것: 반투명 박스라면 정렬 효과가 명백하다. Constant MAT Alpha 0.5로 정렬 전/후를 비교하면 깊이 오류가 사라진다.

노드 ↔︎ GLSL 매핑

Culling — 노드: Grid POP → GLSL POP (visible) → Delete POP (visible==0) → Geometry COMP의 instanceop. Culling — Advanced POP: Grid POP → GLSL Advanced POP (scan + scatter, point count 변경) → Geometry COMP의 instanceop. 두 노드가 한 노드로 합쳐지지만 셰이더가 길어진다.

Sort — 노드: Sort POP 한 줄. 내부는 GPU radix sort.

Manual radix sort 의사 코드 (32-bit key, 8-bit 4 pass):

// per pass:
//   1) histogram: 각 thread가 키의 해당 byte → 256-bin 히스토그램
//   2) prefix scan: 히스토그램을 exclusive scan → bin별 시작 위치
//   3) scatter: scan 결과로 데이터를 출력 위치에 재배치
// pass 4번 → 32-bit 다 정렬

FidelityFX Parallel Sort는 위 의사 코드를 GPU 최적화한 구현이다. Sort POP은 이 100줄을 노드 한 줄로 압축한다.

확인 질문 (Self-check)

  1. instancing이 vertex shader 호출 수를 줄이지 않는다면, 인스턴스 수가 커질 때의 다음 병목은 어디인가? 각 병목에 대응하는 기법은 무엇인가?
  2. exclusive prefix scan이 compaction에 필요한 이유는? scan의 출력값이 무엇을 의미하는가?
  3. frustum 안인지 판정하는 식 abs(clip.xyz) <= vec3(clip.w)가 왜 NDC 비교(/w[-1,+1])와 같은가?
  4. Sort POP의 내부 알고리즘이 GPU radix sort라는 사실이 노드를 쓰는 사용자에게 무엇을 의미하는가 (성능 특성, N에 대한 스케일링)?
  5. Advanced POP으로 element 수를 줄였을 때 unchanged input attribute의 reference pass-through가 어떻게 되는가? (glsl_pop_deep.md §4 마지막 인용 참조)

연결 고리

이 챕터의 한 줄 명제

LOD·frustum culling·sorting을 GPU에 옮기면 결국 dispatch와 prefix sum의 조합으로 환원된다. Sort POP과 Delete POP은 그 환원을 노드 한 줄로 노출한 인터페이스다.