GPU 병렬 처리: 셰이더는 어떻게 도는가 — 한 줄 한 줄 직접 구현
셰이더 코드를 쓰기 전에, 그 코드가 GPU 위에서 물리적으로 어떻게 실행되는지를 먼저 몸으로 이해해야 합니다. 이 페이지는 코드 한 줄이 아니라 실행 모델 자체를 해부합니다. CPU와 GPU의 근본적인 차이, 수천 개의 코어가 같은 코드를 어떻게 나눠 도는지(SIMT), 워프·웨이브프론트·스레드 그룹이라는 계층, 그리고 초심자가 가장 자주 밟는 지뢰인 분기 발산(branch divergence)을 개념도·수치 예제·HLSL/C++ 대응까지 끝까지 파고듭니다. 이 모델을 이해하면 이후의 버텍스·픽셀·컴퓨트 셰이더가 "왜 그렇게 생겼는지"가 한눈에 들어옵니다.
1. CPU와 GPU는 애초에 다른 종류의 프로세서입니다
무엇을 이해하려는가: 셰이더가 "GPU 위에서 병렬로 도는 작은 프로그램"이라는 말의 진짜 의미. 왜 필요한가: 이 차이를 모르면 CPU에서 빠른 코드를 그대로 GPU에 옮겨 놓고 왜 느린지 몰라 헤매게 됩니다. GPU에 맞는 코드는 CPU와 사고방식이 다릅니다.
CPU는 소수의 강력한 코어(보통 4~16개)가 순차적인 작업을 최대한 빨리 처리하도록 설계됐습니다. 분기 예측기, 거대한 캐시, 명령어 재정렬 같은 온갖 장치가 "한 스레드의 작업을 얼마나 빨리 끝내느냐"에 투자돼 있습니다. 반면 GPU는 수천 개의 단순한 코어가 같은 코드를 서로 다른 데이터에 대해 동시에 실행하도록 설계됐습니다. 코어 하나하나는 CPU 코어보다 훨씬 느리고 단순하지만, 수천 개가 동시에 일하므로 "같은 계산을 아주 많은 데이터에 반복"하는 작업에서는 압도적으로 빠릅니다.
셰이더는 바로 이 "같은 계산 × 아주 많은 데이터"에 딱 맞는 물건입니다. 우리가 셰이더 한 벌을 작성하면, GPU는 그것을 수십만~수백만 번, 매번 다른 입력(정점, 픽셀, 파티클)으로 병렬 호출합니다. 즉 우리는 "한 개의 항목을 어떻게 처리할지"만 쓰고, "몇 개를, 어떻게 나눠 돌릴지"는 GPU가 알아서 합니다.
| CPU | GPU | |
|---|---|---|
| 코어 수 | 적음 (수 개~수십 개) | 많음 (수백~수천 개) |
| 코어 하나의 성격 | 강력·복잡 (분기예측·거대 캐시) | 단순·느림 (계산에 집중) |
| 잘하는 일 | 순차 로직, 조건 분기 많은 코드 | 같은 연산의 대량 병렬 |
| 대표 작업 | 게임 로직, OS, 파일 처리 | 정점 변환, 픽셀 셰이딩, 파티클 |
셰이더 스테이지별 "몇 번 도는가"
각 셰이더가 무엇 하나당 한 번 도는지를 정확히 알아야, 어디에 계산을 얹으면 몇 배로 불어나는지 감이 옵니다. 다음이 핵심 규칙입니다.
- 버텍스 셰이더 — 정점(vertex) 하나당 한 번 호출됩니다. 정점이 10만 개면 10만 번 실행됩니다. 위치 변환, 정점 애니메이션이 여기 들어갑니다.
- 픽셀(프래그먼트) 셰이더 — 화면에 찍힐 후보 픽셀 하나당 한 번 호출됩니다. 1080p(1920×1080) 화면을 꽉 채우면 약 207만 번입니다. 조명·텍스처·후처리가 여기 들어갑니다.
- 컴퓨트 셰이더 — 우리가 직접 정의한 작업 항목(work item)마다 한 번 호출됩니다. 파티클 10만 개면 10만 개의 스레드를 우리가 요청합니다.
2. SIMT: 스레드를 묶어서 한 명령어로 굴린다
무엇을 이해하려는가: GPU가 수백만 개의 셰이더 호출을 "낱개"가 아니라 "묶음"으로 실행한다는 사실. 왜 필요한가: 이 묶음의 존재를 모르면 뒤에 나올 분기 발산이 왜 그렇게 비싼지, 왜 스레드 개수를 32/64의 배수로 맞추라고 하는지 전혀 이해할 수 없습니다.
수학/원리: 하나의 명령어, 여러 개의 데이터
GPU는 셰이더 호출들을 워프(warp, NVIDIA 용어) 또는 웨이브프론트(wavefront, AMD 용어)라 불리는 고정 크기 묶음으로 뭉쳐서 실행합니다. 크기는 하드웨어마다 다르지만 보통 32개(NVIDIA) 또는 64개(AMD)입니다. 한 워프에 속한 모든 스레드는 같은 명령어를, 같은 클럭 사이클에 실행합니다. 오직 다루는 데이터(레지스터 값)만 스레드마다 다릅니다.
이 방식을 SIMT(Single Instruction, Multiple Threads) — "하나의 명령어, 여러 개의 스레드" — 라고 부릅니다. 이름을 성분별로 뜯어보면 이렇습니다.
- Single Instruction: 이번 클럭에 실행되는 기계어 명령어는 워프 전체에 딱 하나입니다.
예를 들어 지금 명령어가
MUL r0, r1, r2라면, 32개 스레드가 전부 이 곱셈을 합니다. - Multiple Threads: 다만 각 스레드의
r1,r2레지스터에 담긴 값은 서로 다릅니다. 그래서 같은 곱셈을 해도 결과r0는 스레드마다 달라집니다.
기하 직관: 32명이 같은 악보를 동시에 연주하는 오케스트라
워프를 지휘자 한 명 아래 32명의 연주자로 상상해 보세요. 악보(명령어 스트림)는 한 부이고, 지휘봉(프로그램 카운터)도 하나입니다. 지휘자가 "1번 마디"를 가리키면 32명이 동시에 1번 마디를 연주합니다. 다만 각자의 악기(데이터)가 다르니 나는 소리(결과)는 다르죠. 절대 어긋날 수 없습니다 — 악보와 지휘봉이 공유되기 때문입니다. 바로 이 "공유된 지휘봉" 때문에, 뒤에서 볼 것처럼, 연주자 절반만 다른 마디로 가야 하는 상황(=분기)이 곤란해집니다.
코드 매핑: numthreads와 워프의 관계
컴퓨트 셰이더에서는 워프의 상위 개념인 스레드 그룹(thread group)의 크기를 우리가
HLSL 어트리뷰트 [numthreads(x,y,z)]로 직접 지정합니다. 반면 그 그룹을 하드웨어가 다시 워프로
쪼개는 것은 자동입니다. 아래 한 줄이 그 선언입니다.
// 스레드 그룹 하나에 64개 스레드 (1차원)
[numthreads(64, 1, 1)]
void CSMain(uint3 dtid : SV_DispatchThreadID) {
uint i = dtid.x; // 이 스레드가 담당할 전역 인덱스
// ... i번째 데이터를 처리 ...
}
이 두 줄을 성분 단위로 해부합니다.
-
[numthreads(64, 1, 1)]— 함수 위에 붙는 어트리뷰트(attribute)입니다. 이 컴퓨트 셰이더의 스레드 그룹 하나가 x=64, y=1, z=1 배열의 스레드로 구성된다는 선언입니다. 즉 그룹당 총 스레드 수는64 × 1 × 1 = 64개입니다. 이 숫자는 임의로 정하는 게 아니라, 하드웨어 워프 크기의 배수로 두는 것이 정석입니다. 64는 NVIDIA(워프 32)에서는 정확히 2개 워프, AMD(웨이브 64)에서는 정확히 1개 웨이브에 딱 맞아떨어져 남는 스레드로 인한 낭비가 없기 때문에 흔히 쓰는 안전한 값입니다. -
uint3 dtid : SV_DispatchThreadID— 함수 인자입니다.uint3은 부호 없는 32비트 정수 3개짜리 벡터,: SV_DispatchThreadID는 시스템 값 시맨틱(system-value semantic)으로, "이 스레드가 디스패치 전체에서 몇 번째인가"를 GPU가 채워 넣어 준다는 뜻입니다. 접두사SV_가 "System Value" — 우리가 넘기는 게 아니라 시스템이 자동으로 주는 값 — 를 의미합니다. -
uint i = dtid.x;— 1차원이므로.x만 씁니다. 이i가 곧 "내가 처리할 배열의 인덱스"가 됩니다. 스레드마다i가0, 1, 2, …로 다르니, 같은 코드가 서로 다른 원소를 처리하는 SIMT 그림이 완성됩니다.
numthreads는 그룹 하나의 크기일 뿐, 전체 스레드 수가 아닙니다. 전체 스레드 수는
그룹 크기 × 그룹 개수이고, 그룹 개수는 C++에서 context->Dispatch(gx, gy, gz)로 따로
지정합니다. 이 둘을 헷갈리면 데이터의 일부만 처리되거나 배열 밖을 건드리게 됩니다. 두 값의 관계는 다음
절에서 정확한 수식으로 다룹니다.
3. 실행 계층: 디스패치 → 스레드 그룹 → 워프 → 스레드
무엇을 이해하려는가: "내가 요청한 스레드"가 어떤 계층으로 쪼개져 하드웨어까지 내려가는지.
왜 필요한가: 이 계층을 알아야 Dispatch 인자와 numthreads를 올바르게
맞물릴 수 있고, 배열 크기가 그룹 크기의 배수가 아닐 때 왜 "범위 체크"가 필요한지 이해됩니다.
아래 개념도는 위에서 아래로 갈수록 더 잘게 쪼개지는 구조를 보여줍니다. 맨 위 "디스패치 전체"는 우리가
Dispatch 한 번으로 요청하는 작업 전체이고, 맨 아래 "스레드"는 셰이더 함수가 실제로 한 번
실행되는 최소 단위입니다.
디스패치 전체
├─ 스레드 그룹 (0,0) ← context->Dispatch(gx,gy,gz) 로 개수 지정
│ ├─ 워프/웨이브 [스레드 0..31] ← 하드웨어가 한 덩어리로 실행 (SIMT)
│ └─ 워프/웨이브 [스레드 32..63]
├─ 스레드 그룹 (1,0)
└─ ...
그룹 내부 스레드 수 = numthreads.x * numthreads.y * numthreads.z
계층을 한 줄씩 해부
-
디스패치 전체 —
Dispatch호출 한 번이 만들어 내는 모든 스레드의 총합입니다. 이 안에 여러 개의 스레드 그룹이 들어갑니다. -
스레드 그룹 (thread group) —
Dispatch(gx, gy, gz)의 세 인자로 개수가 정해지는 격자입니다. 예:Dispatch(1563, 1, 1)이면 1563개의 그룹이 1차원으로 늘어섭니다. 같은 그룹 안의 스레드끼리는 공유 메모리(groupshared)와 동기화(GroupMemoryBarrier)를 쓸 수 있지만, 다른 그룹끼리는 그럴 수 없습니다. 이 경계가 중요한 이유입니다. -
워프/웨이브 — 하드웨어가 한 그룹을 자동으로 쪼갠 32/64개짜리 실행 단위입니다. 앞 절의
SIMT가 적용되는 실제 단위가 바로 이것입니다. 우리가 직접 지정하지 않고,
numthreads로 정한 그룹 크기를 하드웨어가 나눕니다. 그룹이 64면 NVIDIA에서는 워프 2개, AMD에서는 웨이브 1개가 됩니다. -
스레드 — 셰이더 함수
CSMain이 한 번 실행되는 최소 단위. 각 스레드는SV_DispatchThreadID로 자기 전역 인덱스를 압니다. -
그룹 내부 스레드 수 = numthreads.x * numthreads.y * numthreads.z— 개념도 맨 아래 줄이 그룹당 스레드 수 공식입니다.[numthreads(8,8,1)]이면8×8×1 = 64개,[numthreads(64,1,1)]이면64개입니다. 2D 이미지 처리에는(8,8,1)처럼 2차원 배치가, 1D 배열(파티클)에는(64,1,1)처럼 1차원 배치가 자연스럽습니다.
수식과 손계산: 그룹 개수 구하기
배열이 COUNT개이고 그룹당 스레드가 LOCAL_SIZE개일 때, 필요한 그룹 개수는
"올림 나눗셈(ceiling division)"으로 구합니다. 정수 연산만으로 올림을 하려면 다음 관용구를 씁니다.
const UINT COUNT = 100000; // 파티클 개수
const UINT LOCAL_SIZE = 64; // numthreads(64,1,1) 과 반드시 일치
// 필요한 스레드 그룹 수 = ceil(COUNT / LOCAL_SIZE)
UINT groups = (COUNT + LOCAL_SIZE - 1) / LOCAL_SIZE;
context->Dispatch(groups, 1, 1);
이 공식을 성분 단위로, 실제 수치를 넣어 손계산해 봅니다.
-
만약 그냥
COUNT / LOCAL_SIZE = 100000 / 64 = 1562.5인데, C++의 정수 나눗셈은 소수점을 버립니다. 그래서1562가 되고, 그룹 1562개 × 64 =99968개 스레드만 생깁니다. 마지막 32개 파티클(99968~99999)이 처리되지 않습니다. 이것이 흔한 오프바이원 버그입니다. -
올림 관용구
(COUNT + LOCAL_SIZE - 1) / LOCAL_SIZE는 이 버림을 보정합니다.(100000 + 64 - 1) / 64 = 100063 / 64 = 1563.48…→ 버림 →1563. 그룹 1563개 × 64 =100032개 스레드가 생겨 10만 개 전부를 덮습니다. -
대신 이제
100032 − 100000 = 32개의 남는 스레드가 생깁니다. 이 여분 스레드가 존재하지 않는particles[100000]이상을 건드리면 메모리 오류입니다. 그래서 셰이더 첫머리에서if (i >= uCount) return;으로 범위 밖 스레드를 즉시 종료시켜야 합니다.
LOCAL_SIZE(C++ 상수)와 numthreads(HLSL)의 숫자는 반드시 같아야 합니다.
HLSL을 [numthreads(128,1,1)]로 바꿔 놓고 C++의 LOCAL_SIZE를 64로 두면, 그룹 개수를
2배로 잡아 절반의 스레드가 남거나 데이터의 절반만 처리됩니다. 두 값은 개념적으로 하나의 상수이므로,
실무에서는 매크로나 공유 헤더로 한 곳에서만 정의하는 것이 안전합니다.
4. 분기 발산(branch divergence): 초심자의 첫 번째 지뢰
무엇을 이해하려는가: 왜 GPU에서는 if가 CPU만큼 "공짜"가 아닌지.
왜 필요한가: 이 하나만 이해해도 초보 셰이더가 몇 배씩 느려지는 원인의 대부분을 피할 수
있습니다. SIMT의 직접적인 결과이므로, 앞 두 절을 이해했다면 자연스럽게 따라옵니다.
원리: 지휘봉이 하나뿐이라 두 마디를 모두 연주해야 한다
2절에서 워프의 32개 스레드는 같은 명령어를 같은 클럭에 실행한다고 했습니다. 프로그램
카운터(지휘봉)가 워프에 하나뿐이기 때문입니다. 그런데 코드에 if (조건)이 있고, 워프 안에서
어떤 스레드는 조건이 참, 어떤 스레드는 거짓이면 어떻게 될까요? 지휘봉은 하나인데 두 갈래로
가야 하는 모순이 생깁니다.
GPU의 해결책은 이렇습니다: 두 갈래를 순차적으로 모두 실행하되, 각 단계에서 "지금 이 갈래에 해당하지 않는 스레드"는 마스킹(masking)으로 잠시 꺼 둡니다(결과를 버립니다).
- 먼저
if가 참인 갈래(then 블록)를 워프 전체가 실행합니다. 이때 조건이 거짓이었던 스레드들은 계산은 하지만 결과가 버려집니다(마스크 off). - 이어서
else갈래를 워프 전체가 다시 실행합니다. 이번엔 반대로 참이었던 스레드들이 꺼집니다. - 결국 워프는 then의 시간 + else의 시간을 둘 다 소비합니다. 각 스레드는 자기에 해당하는 결과만 남기지만, 총 실행 시간은 두 갈래의 합입니다.
이것이 "분기가 갈리면 둘 다의 비용을 낸다"는 말의 정확한 의미입니다. 반대로, 워프 안의 32개 스레드가 전부 같은 갈래로 가면(모두 참이거나 모두 거짓), 안 가는 갈래는 아예 건너뛰므로 발산 비용이 0입니다. 문제는 갈림 그 자체가 아니라, 한 워프 안에서 갈리는 것입니다.
기하 직관: 체스판처럼 뒤섞인 조건 vs. 영역으로 뭉친 조건
화면 픽셀을 예로 들어 보겠습니다. GPU는 인접한 픽셀들을 한 워프로 묶는 경향이 있습니다. 이때 조건이 픽셀마다 체스판처럼 뒤죽박죽이면 거의 모든 워프가 참·거짓이 섞여 매번 두 갈래를 다 돌아야 합니다(최악: 워프마다 2배 비용). 반대로 화면을 큰 영역으로 나눠 왼쪽은 전부 A, 오른쪽은 전부 B처럼 조건이 영역 단위로 뭉쳐 있으면, 대부분의 워프는 한 갈래만 타므로 발산이 거의 없습니다. 같은 로직이라도 "조건이 공간적으로 얼마나 일관되냐"가 성능을 가릅니다.
코드 매핑: 발산하는 코드와 발산 없는(branchless) 코드
아래 두 스니펫은 같은 결과를 내지만, 위는 워프가 갈리면 두 갈래를 다 돌고, 아래는 분기 자체가 없어 항상 한 경로만 돕니다.
// 워프 안에서 t 가 0.5 를 기준으로 갈리면 두 갈래를 모두 실행한다
float3 color;
if (t < 0.5) {
color = colorA; // 절반의 스레드가 여기
} else {
color = colorB; // 나머지 절반이 여기 → 워프는 둘 다 실행
}
// step(edge, x): x < edge 면 0, 아니면 1 (분기 없이 계산으로 판정)
float sel = step(0.5, t); // t<0.5 → 0, t>=0.5 → 1
float3 color = lerp(colorA, colorB, sel);
// lerp(a, b, s) = a*(1-s) + b*s → s=0 이면 colorA, s=1 이면 colorB
아래 branchless 버전을 성분 단위로 해부합니다.
-
step(0.5, t)— 계단 함수입니다. 수학적으로t < 0.5면0.0,t >= 0.5면1.0을 돌려줍니다. 중요한 점은 이것이 if 문이 아니라 산술 연산이라, 워프의 모든 스레드가 똑같은 명령어로 계산한다는 것입니다. 갈라지는 지휘봉이 없습니다. -
lerp(colorA, colorB, sel)— 선형 보간. 수식은colorA*(1-sel) + colorB*sel입니다.sel이0이면 결과는colorA,1이면colorB가 되어 if/else와 똑같은 선택을 산술만으로 해냅니다. -
손계산:
t = 0.3→step(0.5, 0.3) = 0→lerp(A,B,0) = A*(1-0)+B*0 = A.t = 0.8→step = 1→lerp(A,B,1) = A*0+B*1 = B. if/else와 결과가 동일합니다.
if보다 lerp()·step()
같은 분기 없는(branchless) 수식을 선호하세요. 단, 분기 안의 계산이 아주 무거운데 워프 대부분이 그
갈래를 건너뛴다면(예: 조기 종료 if (i >= uCount) return;) 분기가 오히려 이득입니다 —
핵심은 "발산이 실제로 일어나느냐"입니다.
if (i >= uCount) return; 같은 경계 체크는 발산을 걱정하지 않아도 됩니다. 마지막
한 워프를 뺀 나머지 워프는 조건이 전부 거짓이라 갈리지 않고, 오직 배열 끝에 걸친 마지막 워프
하나만 살짝 갈릴 뿐이기 때문입니다. 발산이 문제가 되는 건 매 워프마다 반복적으로 조건이 섞일
때입니다. 모든 if를 겁내지 말고, "이 조건이 워프 단위로 얼마나 자주 갈리나"를 기준으로 판단하세요.
5. C++ 쪽: Dispatch로 이 병렬 실행을 실제로 시동한다
지금까지의 실행 모델은 결국 C++에서 Dispatch 한 줄로 방아쇠가 당겨집니다. 컴퓨트 셰이더를
바인딩하고 스레드 그룹 개수를 넘겨 GPU에 "이만큼 병렬로 돌려라"라고 명령하는 부분을 봅니다. (버텍스/픽셀
셰이더는 Draw 계열이 같은 역할을 하지만, 병렬 개수를 우리가 직접 숫자로 넘기는 컴퓨트가 실행
모델을 가장 투명하게 보여 줍니다.)
// computeCS = compileShader(..., "CSMain", "cs_5_0") + CreateComputeShader 로 만든 것
context->CSSetShader(computeCS.Get(), nullptr, 0); // 컴퓨트 셰이더 바인딩
context->CSSetConstantBuffers(0, 1, simCB.GetAddressOf()); // b0 상수 버퍼 연결
UINT initCounts = 0;
context->CSSetUnorderedAccessViews(0, 1, ssboUAV.GetAddressOf(), &initCounts); // u0 UAV 연결
// 필요한 스레드 그룹 수 = ceil(COUNT / LOCAL_SIZE)
UINT groups = (COUNT + LOCAL_SIZE - 1) / LOCAL_SIZE;
context->Dispatch(groups, 1, 1); // ★ 병렬 실행 시동
실행 모델과 직접 연결되는 줄만 골라 해부합니다.
-
context->CSSetShader(computeCS.Get(), nullptr, 0)— 파이프라인의 컴퓨트 스테이지에 우리 셰이더를 겁니다. 첫 인자computeCS.Get()은ComPtr이 감싼 원시 포인터를 꺼내는 것이고, 둘째·셋째 인자(nullptr, 0)는 클래스 링키지 관련으로 여기서는 쓰지 않아 기본값입니다. -
context->CSSetUnorderedAccessViews(0, 1, ssboUAV.GetAddressOf(), &initCounts)— 셰이더의register(u0)에 우리 버퍼의 UAV를 연결합니다. 첫 인자0이 슬롯 번호(u0), 둘째1이 연결할 뷰 개수, 셋째가 뷰 배열의 주소입니다.GetAddressOf()는 "포인터의 포인터"(ID3D11UnorderedAccessView**)를 넘겨야 하기 때문에 씁니다. -
UINT groups = (COUNT + LOCAL_SIZE - 1) / LOCAL_SIZE;— 3절에서 손계산한 올림 나눗셈. 여기서 나온groups가 다음 줄로 넘어갑니다. -
context->Dispatch(groups, 1, 1)— 이 한 줄이 개념도의 "디스패치 전체"를 실제로 만들어 냅니다. 세 인자(gx, gy, gz)는 스레드 그룹 격자의 크기입니다. 여기서는 1차원이라(groups, 1, 1). 실제 실행되는 총 스레드 수는groups × 1 × 1 × (numthreads의 곱)= 1563 × 64 = 100032개 — 앞서 계산한 값과 정확히 일치합니다.
Dispatch는 CPU가 GPU에 "명령을 큐에 넣는" 비동기 호출입니다. 이 줄이 반환됐다고 계산이 끝난
게 아닙니다. 결과를 CPU로 읽어오려면 스테이징 버퍼로 CopyResource 후 Map해야 하고,
다른 GPU 스테이지에서 이 버퍼를 읽으려면 먼저 UAV 바인딩을 nullptr로 해제해야 합니다
(같은 리소스가 UAV로 묶인 채 다른 스테이지에 바인딩되면 D3D 디버그 레이어가 경고하고 무시합니다).
이 상세는 4장 허브의 컴퓨트 셰이더 절에서 다룹니다.
6. DirectXMath / D3D API 대응 정리
이 페이지는 실행 모델을 다루므로 무거운 수학은 없지만, 앞으로 셰이더에서 계속 쓸 규약과 API 대응을 한 번에 정리해 둡니다. 이 규약은 이 학습 트랙 전체에서 통일해서 씁니다.
| 개념 / 원본 | HLSL / D3D API | 비고 |
|---|---|---|
| 스레드 그룹 개수 지정 | ID3D11DeviceContext::Dispatch(gx,gy,gz) |
OpenGL의 glDispatchCompute에 대응 |
| 그룹당 스레드 수 선언 | [numthreads(x,y,z)] |
GLSL의 layout(local_size_x=…)에 대응 |
| 전역 스레드 인덱스 | SV_DispatchThreadID |
GLSL의 gl_GlobalInvocationID에 대응 |
| 컴퓨트 셰이더 바인딩 | CSSetShader |
버텍스/픽셀은 VSSetShader/PSSetShader |
| 읽기·쓰기 버퍼(UAV) | RWStructuredBuffer + CSSetUnorderedAccessViews |
OpenGL의 SSBO에 대응 |
| 행렬·벡터 라이브러리(CPU) | DirectXMath (XMMATRIX, XMVECTOR) |
행벡터, row-major 저장 |
v' = v * M, HLSL에서는 mul(v, M),
왼손 좌표계, 클립 공간 z는 [0,1], 텍스처 V축은
위→아래를 씁니다. CPU 라이브러리는 직접 구현보다 DirectXMath 같은 검증된 라이브러리를
쓰는 것을 권장하며, 이 트랙의 직접 구현 코드는 원리를 눈으로 보기 위한 학습용입니다. DirectXMath는
행벡터·row-major이므로 cbuffer로 행렬을 올리기 전 CPU에서 XMMatrixTranspose로 한 번 전치해야
셰이더의 mul(matrix, vector) 관례와 맞습니다(자세한 건 버텍스 셰이더 절 참고).
7. 한 장으로 요약
- CPU vs GPU: CPU는 소수의 강한 코어(순차), GPU는 수천 개의 단순 코어(대량 병렬). 셰이더는 "같은 계산 × 많은 데이터"에 최적.
- 스테이지별 실행 횟수: 버텍스=정점당, 픽셀=픽셀당(수백만), 컴퓨트=작업 항목당. 무거운 계산은 픽셀→버텍스로 끌어올린다.
- SIMT: 32/64개 스레드가 워프/웨이브로 묶여 같은 명령어를 같은 클럭에 실행. 데이터만 다르다.
- 계층: 디스패치 → 스레드 그룹(
Dispatch) → 워프(자동) → 스레드. 그룹당 스레드 수 =numthreads의 곱. - 그룹 개수:
ceil(COUNT/LOCAL_SIZE)로 올림, 셰이더에서if (i>=uCount) return;로 여분 스레드 정리. - 분기 발산: 한 워프 안에서
if가 갈리면 두 갈래를 모두 실행 → 비용 2배. 조건을 공간적으로 뭉치거나lerp/step으로 branchless 처리.
이제 실행 모델이 손에 잡혔으니, 다음 페이지에서 이 GPU 위에 실제로 셰이더를 올리는 C++ 헬퍼 (컴파일·생성·바인딩)를 만들어 봅니다.