← 개념 · 데모 가이드 | 구현 허브 | 4장. GPU · 셰이더 | Compute Shader
IMPLEMENTATION · CHAPTER 4

Compute Shader — 한 줄 한 줄 직접 구현

컴퓨트 셰이더(Compute Shader)는 정점·픽셀이라는 고정 역할을 벗어나, GPU를 순수한 병렬 계산기로 쓰는 방법입니다. 화면에 삼각형을 그리는 것과 무관하게, 우리가 정의한 스레드 격자(thread grid) 위에서 수십만 개의 스레드가 동시에 버퍼·텍스처를 읽고 씁니다. 이 페이지에서는 파티클 10만 개를 매 프레임 물리 적분하는 시뮬레이션을 예제로, HLSL 컴퓨트 셰이더와 이를 구동하는 C++/Direct3D 11 코드를 한 줄, 성분 하나까지 해부합니다. [numthreads]·SV_DispatchThreadID·RWStructuredBuffer·UAV· Dispatch·바인딩 해제까지, 왜 이렇게 쓰는지 그 원리와 기하 직관, 그리고 D3D11 API 대응을 빠짐없이 짚습니다.

규약 이 가이드는 D3D 규약으로 통일합니다: 행벡터 v' = v * M(HLSL mul(v, M)), 행 우선 저장, 왼손 좌표계, 클립 z ∈ [0,1], 텍스처 V축 위→아래. 컴퓨트 셰이더 자체는 행렬 변환을 거의 쓰지 않으므로 이 규약과 직접 부딪히는 지점은 적지만, C++ 구조체와 HLSL 구조체의 메모리 레이아웃 일치라는 또 다른 규약이 핵심입니다. 아래에서 자세히 다룹니다. 실무에서는 검증된 라이브러리·헬퍼를 쓰는 것을 권장하며, 이 페이지의 직접 구현은 학습용입니다.

0. 무엇을, 왜 만드나

지금까지의 정점 셰이더·픽셀 셰이더는 렌더 파이프라인이라는 컨베이어 벨트의 정해진 자리에 놓입니다. 정점 셰이더는 "정점 하나당 한 번", 픽셀 셰이더는 "픽셀 하나당 한 번" 자동으로 불려집니다. 우리가 직접 "몇 번 돌려라"라고 지시할 수 없고, 입력·출력의 형태도 파이프라인이 강제합니다.

컴퓨트 셰이더는 이 벨트에서 완전히 떨어져 나온, 범용 GPU 연산(GPGPU)입니다. 삼각형도, 래스터화도, 픽셀도 없습니다. 대신 우리가 직접 "스레드를 이만큼 띄워라"라고 지시하고 (Dispatch), 각 스레드는 자기 번호(SV_DispatchThreadID)를 받아 UAV(Unordered Access View)를 통해 버퍼·텍스처의 원하는 위치를 자유롭게 읽고 씁니다.

컴퓨트 셰이더는 Direct3D 11의 표준 기능이며, 셰이더 모델 cs_5_0 프로파일로 컴파일합니다. 이 예제의 전체 흐름은 다음 다섯 단계입니다.

  1. 파티클 배열을 담을 Structured Buffer를 만든다 (CreateBuffer + D3D11_RESOURCE_MISC_BUFFER_STRUCTURED)
  2. 그 버퍼에 대한 UAV(ID3D11UnorderedAccessView)를 만든다 (CreateUnorderedAccessView)
  3. 매 프레임 상수(델타타임·중력·개수)를 cbuffer에 올린다
  4. CSSetShader + CSSetUnorderedAccessViews로 바인딩하고 Dispatch로 스레드 그룹 개수를 지정해 실행
  5. 다음 단계에서 이 버퍼를 읽으려면 UAV 바인딩을 해제(nullptr 재설정)한 뒤 SRV/VB로 다시 바인딩한다

1. HLSL 컴퓨트 셰이더 — 파티클 물리 적분

먼저 GPU에서 실제로 도는 코드입니다. 이 짧은 셰이더 하나가 파티클 하나를 처리하며, GPU가 이것을 동시에 수만 번 실행합니다. "내가 몇 번째 파티클인가"는 각 스레드가 받는 번호로 구분합니다. 전체를 먼저 본 뒤, 한 줄씩 뜯어보겠습니다.

shaders/particles_cs.hlslhlsl
struct Particle {
    float4 pos;   // xyz = 위치, w = 수명
    float4 vel;   // xyz = 속도, w = 여분(정렬용)
};

// 읽고 쓰는 구조화 버퍼(UAV), u0 슬롯
RWStructuredBuffer<Particle> particles : register(u0);

cbuffer SimCB : register(b0) {
    float  uDt;         // 프레임 델타타임
    uint   uCount;      // 파티클 개수 (범위 체크용)
    float2 _pad0;
    float3 uGravity;    // 예: (0, -9.8, 0)
    float  _pad1;
};

// 스레드 그룹 하나에 64개 스레드 (1차원)
[numthreads(64, 1, 1)]
void CSMain(uint3 dtid : SV_DispatchThreadID) {
    uint i = dtid.x;                           // 전역 스레드 인덱스
    if (i >= uCount) return;                    // 범위 밖 스레드는 종료

    Particle p = particles[i];
    p.vel.xyz += uGravity * uDt;               // 중력 적분
    p.pos.xyz += p.vel.xyz * uDt;              // 위치 적분
    p.pos.w   -= uDt;                          // 수명 감소

    // 바닥에 닿으면 튕김 + 리스폰(수명 소진 시)
    if (p.pos.y < 0.0) { p.pos.y = 0.0; p.vel.y *= -0.6; }
    if (p.pos.w <= 0.0) { p.pos.xyz = float3(0,0,0); p.pos.w = 3.0; }

    particles[i] = p;                          // 다시 써넣는다
}

1.1 struct Particle — GPU와 CPU가 공유할 데이터 한 칸

무엇을 만드나: 파티클 하나의 상태를 담는 데이터 구조입니다. 왜 필요한가: 버퍼는 결국 바이트의 나열일 뿐이라, "몇 바이트마다 하나의 파티클이고, 그 안에서 어느 바이트가 위치이고 어느 바이트가 속도인가"를 GPU와 CPU가 똑같이 약속해야 합니다. 이 struct가 그 약속입니다.

수식 → 직관 → 코드: 파티클 하나는 물리에서 "질점(point mass)"입니다. 질점의 상태는 위치 x와 속도 v 두 벡터로 완전히 기술됩니다. 여기에 "얼마나 더 살아있나"라는 수명 스칼라를 더해, 이 struct질점 하나의 완전한 스냅샷이 됩니다. 이 한 칸이 버퍼 안에 10만 개 줄지어 있고, 스레드 10만 개가 각자 자기 칸을 갱신합니다.

함정 HLSL의 float4는 정확히 16바이트(4바이트 float × 4)입니다. 따라서 이 Particle16 + 16 = 32바이트입니다. 뒤에서 C++ struct Particle { XMFLOAT4 pos; XMFLOAT4 vel; }정확히 32바이트여야 하고, 이 값이 StructureByteStride로 GPU에 전달됩니다. 한쪽이라도 크기가 어긋나면 파티클이 엉뚱한 바이트를 자기 데이터로 읽어 화면이 폭발합니다. 그래서 처음부터 float3가 아니라 float4로 깔끔하게 16의 배수로 맞춘 것입니다.

1.2 RWStructuredBuffer<Particle> particles : register(u0)

무엇인가: 컴퓨트 셰이더가 읽고(Read) 쓰는(Write) 구조화 버퍼 선언입니다. 이 한 줄이 이 페이지의 핵심입니다. 시그니처를 조각내 보겠습니다.

기하/구조 직관: 이 버퍼를 거대한 사물함(로커) 한 줄이라고 상상해 보세요. 사물함 한 칸이 Particle 하나(32바이트)이고, 칸이 10만 개 있습니다. 스레드 i번은 정확히 i번 칸만 열어 내용을 꺼내(particles[i] 읽기), 물리로 갱신하고, 다시 넣습니다(particles[i] = p 쓰기). 각 스레드가 서로 다른 칸을 건드리므로 충돌이 없고, 이것이 GPU가 10만 칸을 동시에 처리할 수 있는 이유입니다.

주의 "Unordered"라는 말은 쓰기 순서를 보장하지 않는다는 뜻입니다. 이 예제처럼 각 스레드가 자기 인덱스만 건드리면 문제가 없지만, 여러 스레드가 같은 칸에 동시에 쓰거나 이웃 칸을 읽으려 하면 경쟁 상태가 생깁니다. 그럴 땐 InterlockedAdd 같은 원자적 연산이나 groupshared 메모리 + 배리어가 필요합니다. 이 예제는 그런 의존이 없어 안전합니다.

1.3 cbuffer SimCB : register(b0) — 모든 스레드가 공유하는 상수

무엇인가: 이번 Dispatch 동안 모든 스레드가 똑같이 읽는 값들입니다. 파티클마다 다른 값(위치·속도)은 버퍼에, 모두에게 공통인 값(델타타임·중력·개수)은 이 상수 버퍼에 둡니다. register(b0)b는 constant buffer 슬롯, 0번입니다.

왜 이렇게 패딩 멤버를 끼워 넣을까요? cbuffer에는 "어떤 원소도 16바이트 경계(하나의 float4 슬롯)를 가로질러 걸치면 안 된다"는 정렬 규칙이 있기 때문입니다. 손으로 바이트를 세어 봅시다.

멤버크기바이트 오프셋속한 16바이트 슬롯
uDt40 ~ 3슬롯0 [0,16)
uCount44 ~ 7슬롯0
_pad088 ~ 15슬롯0 (여기서 첫 16바이트 정확히 채움)
uGravity1216 ~ 27슬롯1 [16,32) — 경계 걸침 없음 ✓
_pad1428 ~ 31슬롯1 (두 번째 16바이트 채움)

_pad0가 없다면 uGravity(float3)가 오프셋 8에서 시작해 8~19바이트를 차지하는데, 이는 슬롯0[0,16)과 슬롯1[16,32)의 경계 16을 가로질러 걸칩니다. 그러면 컴파일러가 uGravity를 강제로 오프셋 16으로 밀어내면서 C++ 구조체와 오프셋이 어긋나는 치명적 버그가 생깁니다. 그래서 명시적으로 _pad0를 넣어 오프셋을 우리 손으로 통제하는 것입니다.

함정 cbuffer의 float3는 "12바이트짜리인데 16바이트처럼 정렬되는" 가장 흔한 실수의 원흉입니다. 가능하면 float4로 쓰거나, 부득이하게 float3를 쓸 땐 예제처럼 명시적 _pad로 오프셋을 눈에 보이게 통제하세요. C++ 쪽 struct SimCB바이트 단위로 똑같이 패딩을 맞춰야 합니다.

1.4 [numthreads(64, 1, 1)] — 그룹 하나의 스레드 배치

무엇인가: 이 컴퓨트 셰이더의 스레드 그룹(thread group) 하나가 몇 개의 스레드를 어떤 모양으로 담는지를 컴파일 타임에 고정하는 특성(attribute)입니다. (64, 1, 1)X방향 64개 × Y 1개 × Z 1개 = 그룹당 64 스레드, 즉 1차원 배치입니다.

왜 이 숫자인가: GPU는 스레드를 워프/웨이브라는 묶음(NVIDIA 32, AMD 32/64)으로 실제 실행합니다. 그룹 크기를 이 묶음 크기의 배수로 잡아야 낭비 없이 꽉 채워 돌릴 수 있어, 64는 두 벤더 모두에서 안전한 무난한 값입니다. 파티클처럼 1차원 배열이면 (64,1,1), 이미지처럼 2차원 격자면 (8,8,1)=64처럼 모양만 바꿉니다.

디스패치와의 관계: C++에서 Dispatch(groups, 1, 1)로 그룹 개수를 지정하면, 전체 스레드 수 = groups × 64가 됩니다. 즉 "그룹당 몇 스레드"는 셰이더가(numthreads), "그룹을 몇 개"는 CPU가(Dispatch) 정합니다. 둘을 곱한 값이 실제로 뜨는 스레드 총수입니다.

성능 그룹 크기가 워프 크기의 배수가 아니면(예: numthreads(50,1,1)) GPU는 64칸을 잡고 14칸을 놀립니다 — 약 22% 낭비입니다. 반대로 그룹이 너무 크면 groupshared 메모리·레지스터 압박으로 점유율이 떨어집니다. 보통 64~256 범위에서 실측으로 고릅니다. 확신이 없으면 64가 안전한 출발점입니다.

1.5 void CSMain(uint3 dtid : SV_DispatchThreadID) — 내가 몇 번인지 알아내기

컴퓨트 셰이더는 반환값이 없습니다(void). 화면에 색을 내보내는 대신 버퍼에 직접 쓰기가 최종 결과이기 때문입니다. 진짜 핵심은 인자에 있습니다.

SV_DispatchThreadID의 정확한 정의: SV_DispatchThreadID = SV_GroupID × numthreads + SV_GroupThreadID 입니다. 즉 "내 그룹의 번호 × 그룹 크기 + 그룹 안에서 내 번호". 손계산으로 확인해 봅시다. 그룹 3번, 그룹 안 5번 스레드라면 X좌표는 3 × 64 + 5 = 197. 이 스레드는 전역에서 197번이므로 197번 파티클을 담당합니다. 이렇게 각 스레드가 겹치지 않는 고유 번호를 얻습니다.

시맨틱의미이 예제에서
SV_DispatchThreadID전체 격자에서의 전역 스레드 좌표파티클 인덱스 i로 사용
SV_GroupID몇 번째 그룹인가이 예제에선 미사용
SV_GroupThreadID그룹 안에서 몇 번째인가 (0~63)groupshared 쓸 때 필요
SV_GroupIndex그룹 내 번호를 1차원으로 평탄화이 예제에선 미사용

1.6 본문 — 물리 적분 한 줄씩

CSMain 본문hlsl
    uint i = dtid.x;                           // 전역 스레드 인덱스
    if (i >= uCount) return;                    // 범위 밖 스레드는 종료

    Particle p = particles[i];
    p.vel.xyz += uGravity * uDt;               // 중력 적분
    p.pos.xyz += p.vel.xyz * uDt;              // 위치 적분
    p.pos.w   -= uDt;                          // 수명 감소

    if (p.pos.y < 0.0) { p.pos.y = 0.0; p.vel.y *= -0.6; }
    if (p.pos.w <= 0.0) { p.pos.xyz = float3(0,0,0); p.pos.w = 3.0; }

    particles[i] = p;                          // 다시 써넣는다

uint i = dtid.x;

1차원 배열이므로 dtidx 성분만 의미가 있습니다(y·z는 numthreads가 1이라 항상 0). 이 i가 바로 "내가 담당할 파티클 번호"입니다. 사물함 비유로는 "내가 열 칸 번호"죠.

if (i >= uCount) return; — 반드시 있어야 하는 안전장치

왜 필요한가: 그룹은 64개 단위라 Dispatch가 띄우는 전체 스레드 수는 파티클 수보다 클 수 있습니다. 예를 들어 파티클 100,000개면 그룹은 ceil(100000 / 64) = 1563개, 전체 스레드는 1563 × 64 = 100,032개입니다. 남는 32개 스레드(인덱스 100,000~100,031)는 버퍼 밖입니다.

이 방어선이 없으면 그 32개 스레드가 존재하지 않는 particles[100000]을 읽고 써서 버퍼 오버런을 일으킵니다. i >= uCount일 때 즉시 return해 범위 밖 스레드는 아무것도 하지 않고 조용히 끝냅니다. 컴퓨트 셰이더의 거의 모든 커널에 등장하는 관용구이니 반드시 습관화하세요.

Particle p = particles[i];

UAV 버퍼에서 내 칸을 로컬 변수 p로 복사합니다. 이후 계산은 레지스터에 있는 p에 대해서만 하고, 맨 마지막에 한 번만 되씁니다. 버퍼(비교적 느린 메모리)를 매번 건드리는 대신 읽기 1번 → 계산 → 쓰기 1번으로 메모리 접근을 최소화하는 좋은 습관입니다.

p.vel.xyz += uGravity * uDt; — 오일러 적분 ①

수학: 뉴턴 역학에서 속도의 변화는 가속도 × 시간입니다: Δv = a · Δt. 중력만 작용한다면 a = g = (0, -9.8, 0). 따라서 v_new = v_old + g · Δt.

기하 직관: 매 프레임 속도 화살표가 아래로 9.8 · Δt만큼 자라납니다. 프레임이 반복되면 속도가 점점 아래로 커지며 낙하가 가속됩니다. 코드 매핑: .xyz 스위즐로 위치의 x·y·z 세 성분에 동시에 적용하고(w=수명은 건드리지 않음), uGravity * uDt는 벡터×스칼라라 각 성분이 uDt배 됩니다. y성분만 예로 들면 uDt=0.016일 때 vel.y += (-9.8) × 0.016 = -0.157씩 매 프레임 감소합니다.

p.pos.xyz += p.vel.xyz * uDt; — 오일러 적분 ②

수학: 위치의 변화는 속도 × 시간입니다: Δx = v · Δt, 즉 x_new = x_old + v · Δt. 방금 갱신한 새 속도로 위치를 옮깁니다 (속도를 먼저 갱신하고 위치를 나중에 갱신하는 이 순서를 세미-임플리싯 오일러라 하며, 단순 오일러보다 진동에 안정적입니다).

직관 + 매핑: "지금 속도로 Δt초 동안 직선으로 움직였다"고 근사하는 것입니다. Δt가 작을수록 실제 곡선 궤적에 가까워집니다. 역시 .xyz로 세 성분에 적용하고 pos.w(수명)는 건드리지 않습니다.

p.pos.w -= uDt; — 수명 감소

pos.w에 실어 둔 수명(초)을 매 프레임 Δt초씩 깎습니다. 3.0초로 시작했다면 약 3초 뒤 0 이하가 되어 아래의 리스폰 조건에 걸립니다. 위치의 남는 w 성분을 이렇게 공짜 저장 공간으로 재활용한 것입니다.

if (p.pos.y < 0.0) { p.pos.y = 0.0; p.vel.y *= -0.6; } — 바닥 튕김

수학/물리: 파티클이 바닥(y=0) 아래로 내려가면, 위치를 바닥으로 고정(pos.y = 0)하고 수직 속도를 반전 + 감쇠합니다(vel.y *= -0.6). 부호를 뒤집으면(-) "튕겨 올라감", 0.6을 곱하면 "40% 에너지를 잃음"입니다. 반발계수(coefficient of restitution) 0.6에 해당합니다.

직관: 공이 바닥에 부딪혀 되튀되, 매번 조금씩 낮게 튀어 결국 멈추는 현실적인 감쇠 바운스입니다. *= -1.0이면 영원히 같은 높이로 튀고(에너지 보존), *= 0이면 그 자리에 붙습니다.

if (p.pos.w <= 0.0) { p.pos.xyz = float3(0,0,0); p.pos.w = 3.0; } — 리스폰

수명이 다하면 파티클을 원점으로 되돌리고 수명을 3.0초로 리셋합니다. 죽은 파티클을 재활용해 분수처럼 끊임없이 뿜어져 나오는 효과를 만듭니다. 참고로 이 예제는 속도를 리셋하지 않아 마지막 속도를 이어받는데, 실무에선 보통 초기 속도도 새로 난수로 부여합니다.

성능 if 분기는 분기 발산(divergence)을 부를 수 있습니다. 같은 워프의 32개 스레드 중 일부만 튕김 조건에 걸리면, GPU는 양쪽 경로를 모두 실행하고 마스크로 결과를 고릅니다. 이 예제는 분기 몸통이 짧아 비용이 작지만, 무거운 분기라면 step·lerp 같은 분기 없는(branchless) 수식으로 바꾸는 것이 GPU 친화적입니다.

particles[i] = p; — 되쓰기

로컬에서 갱신을 끝낸 p내 칸에 통째로 저장합니다. 이 쓰기가 UAV를 통해 실제 버퍼 메모리에 반영되고, 다음 프레임의 시뮬레이션 또는 렌더링이 이 갱신된 값을 읽습니다. 읽기는 함수 시작에 1번, 쓰기는 끝에 1번 — 앞서 말한 메모리 접근 최소화가 여기서 완성됩니다.

2. C++ / Direct3D 11 — 버퍼·UAV·디스패치

이제 위 셰이더를 실제로 돌리는 CPU 쪽 코드입니다. 크게 (1) 버퍼 생성 → (2) UAV 생성 → (3) 매 프레임 상수 업로드·바인딩·디스패치 → (4) 바인딩 해제 순서입니다. 한 덩어리로 본 뒤 나눠 해부합니다.

src/particles.cpp (요지)cpp
#include <d3d11.h>
#include <DirectXMath.h>
#include <wrl/client.h>
#include <vector>
using Microsoft::WRL::ComPtr;
using namespace DirectX;

struct Particle { XMFLOAT4 pos; XMFLOAT4 vel; };

const UINT COUNT = 100000;
const UINT LOCAL_SIZE = 64;

// device, context 는 D3D11 초기화에서 만든 것.
ComPtr<ID3D11Device>        device;   /* ... */
ComPtr<ID3D11DeviceContext> context;  /* ... */

// ── 초기화 ──
std::vector<Particle> init(COUNT);
for (auto& p : init) {
    p.pos = XMFLOAT4(0.0f, 0.0f, 0.0f, 3.0f);             // 위치, 수명
    p.vel = XMFLOAT4((rand()%200-100)/50.0f, 5.0f, 0.0f, 0.0f);
}

// 구조화 버퍼: 셰이더 접근용(UAV) + CPU에서 개별 요소 스트라이드 지정
D3D11_BUFFER_DESC bd{};
bd.ByteWidth           = UINT(init.size() * sizeof(Particle));
bd.Usage               = D3D11_USAGE_DEFAULT;
bd.BindFlags           = D3D11_BIND_UNORDERED_ACCESS | D3D11_BIND_SHADER_RESOURCE;
bd.MiscFlags           = D3D11_RESOURCE_MISC_BUFFER_STRUCTURED;
bd.StructureByteStride = sizeof(Particle);
D3D11_SUBRESOURCE_DATA sd{ init.data(), 0, 0 };
ComPtr<ID3D11Buffer> ssbo;
device->CreateBuffer(&bd, &sd, &ssbo);

// 이 버퍼에 대한 UAV (셰이더의 register(u0) 에 연결)
D3D11_UNORDERED_ACCESS_VIEW_DESC ud{};
ud.Format              = DXGI_FORMAT_UNKNOWN;   // 구조화 버퍼는 UNKNOWN
ud.ViewDimension       = D3D11_UAV_DIMENSION_BUFFER;
ud.Buffer.NumElements  = COUNT;
ComPtr<ID3D11UnorderedAccessView> ssboUAV;
device->CreateUnorderedAccessView(ssbo.Get(), &ud, &ssboUAV);

// computeCS 는 compileShader(..., "CSMain", "cs_5_0") + CreateComputeShader 로 만든 것
ComPtr<ID3D11ComputeShader> computeCS;  /* ... */
ComPtr<ID3D11Buffer> simCB;             /* createConstantBuffer<SimCB>(device.Get()) */

// ── 매 프레임: 시뮬레이션 한 스텝 ──
struct SimCB { float dt; UINT count; float pad0[2]; XMFLOAT3 gravity; float pad1; };
SimCB s{ dt, COUNT, {}, XMFLOAT3(0.0f, -9.8f, 0.0f), 0 };
updateConstantBuffer(context.Get(), simCB.Get(), s);   // 앞서 만든 Map/Unmap 헬퍼

context->CSSetShader(computeCS.Get(), nullptr, 0);
context->CSSetConstantBuffers(0, 1, simCB.GetAddressOf());
UINT initCounts = 0;
context->CSSetUnorderedAccessViews(0, 1, ssboUAV.GetAddressOf(), &initCounts);

// 필요한 스레드 그룹 수 = ceil(COUNT / LOCAL_SIZE)
UINT groups = (COUNT + LOCAL_SIZE - 1) / LOCAL_SIZE;
context->Dispatch(groups, 1, 1);

// 이후 이 버퍼를 렌더링/읽기에 쓰려면 UAV 바인딩을 반드시 해제한다.
// (같은 리소스가 UAV로 묶인 채 다른 스테이지에 바인딩되면 D3D가 경고하고 무시함)
ID3D11UnorderedAccessView* nullUAV = nullptr;
context->CSSetUnorderedAccessViews(0, 1, &nullUAV, &initCounts);

// 이후: 같은 ssbo 에 SRV/VB 뷰를 만들어 점으로 렌더링하거나,
// STAGING 버퍼로 CopyResource 후 Map 으로 CPU에서 읽어올 수도 있다.

2.1 헤더·타입·상수

struct Particle { XMFLOAT4 pos; XMFLOAT4 vel; };HLSL의 struct Particle과 바이트 단위로 정확히 일치합니다. XMFLOAT4는 16바이트이므로 이 구조체도 32바이트로, HLSL과 완벽히 맞습니다. 이것이 앞서 강조한 "양쪽 레이아웃 일치"의 실물입니다.

COUNT = 100000은 파티클 수, LOCAL_SIZE = 64는 셰이더의 numthreads(64,...)반드시 같은 값이어야 합니다(그룹 수 계산에 쓰이므로). 셰이더의 64와 C++의 64는 손으로 맞추는 약속이며, 어긋나면 일부 파티클이 갱신되지 않거나 초과 실행됩니다.

2.2 초기 파티클 생성

std::vector<Particle> init(COUNT)로 10만 개짜리 배열을 CPU에서 만들고 초기값을 채웁니다.

for (auto& p : init)auto&참조라 각 원소를 복사 없이 직접 수정합니다. &를 빼면 사본을 수정하게 되어 원본이 안 바뀌는 흔한 함정이 됩니다.

2.3 D3D11_BUFFER_DESC — 구조화 버퍼 만들기

GPU 메모리에 파티클 버퍼를 올립니다. D3D11_BUFFER_DESC bd{}{}값 초기화로 모든 필드를 0으로 밀어 둡니다(쓰레기값 방지). 필드별로 봅시다.

필드의미
ByteWidthinit.size() * sizeof(Particle)버퍼 총 크기 = 100000 × 32 = 3,200,000바이트(약 3MB)
UsageD3D11_USAGE_DEFAULTGPU가 읽고 쓰는 기본 용도. CPU 직접 접근은 안 함
BindFlagsUNORDERED_ACCESS | SHADER_RESOURCEUAV(컴퓨트 쓰기) SRV(렌더 읽기)로 쓸 수 있게
MiscFlagsBUFFER_STRUCTURED"구조화 버퍼"임을 표시 — 원소 타입이 있는 배열
StructureByteStridesizeof(Particle) = 32원소 하나의 바이트 크기. HLSL Particle과 일치해야 함

BindFlags|(비트 OR)로 두 플래그를 합친 이유는, 같은 버퍼를 컴퓨트에서는 UAV로 쓰고 나중에 렌더에서는 SRV(또는 정점 버퍼)로 읽기 위해서입니다. 한 리소스에 여러 용도를 미리 열어 둔 것이죠.

D3D11_SUBRESOURCE_DATA sd{ init.data(), 0, 0 }초기 데이터 포인터입니다. 첫 인자가 CPU의 파티클 배열 시작 주소이고, 이 버퍼를 만드는 순간 GPU로 복사됩니다. 나머지 두 0은 버퍼에는 무의미한 행/깊이 피치입니다.

device->CreateBuffer(&bd, &sd, &ssbo) — 서술자(&bd)와 초기 데이터(&sd)로 실제 GPU 버퍼를 만들어 ssbo(ComPtr<ID3D11Buffer>)에 받습니다. &ssbo가 아니라 ComPtr의 주소 연산자가 내부 포인터의 주소를 넘겨줍니다.

2.4 CreateUnorderedAccessView — 셰이더가 볼 창(view)

버퍼(원본 데이터)와 UAV(그 데이터를 어떻게 볼지 알려주는 뷰)는 별개입니다. 셰이더는 버퍼를 직접 만지지 않고 뷰를 통해서만 접근합니다. UAV는 "쓰기 가능한 뷰"입니다.

device->CreateUnorderedAccessView(ssbo.Get(), &ud, &ssboUAV) — 첫 인자 ssbo.Get()은 뷰가 가리킬 실제 버퍼(ComPtr에서 raw 포인터를 꺼내는 .Get()), 둘째는 서술자, 셋째는 결과 UAV를 받을 ssboUAV입니다. 이 ssboUAV가 셰이더의 register(u0)에 꽂힙니다.

2.5 컴퓨트 셰이더·상수 버퍼 객체

computeCS는 HLSL을 compileShader(..., "CSMain", "cs_5_0")로 컴파일한 바이트코드를 CreateComputeShader로 감싼 것입니다. 진입점 이름 "CSMain"은 셰이더의 함수명과 정확히 일치해야 하고, 프로파일 "cs_5_0"이 "컴퓨트 셰이더 5.0"을 뜻합니다 (정점은 vs_5_0, 픽셀은 ps_5_0). simCBSimCB 크기만큼의 상수 버퍼입니다.

2.6 매 프레임: 상수 채우기

struct SimCB { float dt; UINT count; float pad0[2]; XMFLOAT3 gravity; float pad1; }; — 이 C++ 구조체는 HLSL cbuffer SimCB바이트 단위로 정확히 대응합니다. 하나씩 짝지으면:

C++HLSL바이트
float dtfloat uDt4
UINT countuint uCount4
float pad0[2]float2 _pad08
XMFLOAT3 gravityfloat3 uGravity12
float pad1float _pad14

합계 32바이트. pad0[2]·pad1이 있어야 gravity가 오프셋 16에 딱 떨어져 HLSL의 정렬과 맞아떨어집니다. 이 패딩을 빼면 gravity가 8바이트 앞으로 밀려 셰이더가 엉뚱한 중력값을 읽습니다.

SimCB s{ dt, COUNT, {}, XMFLOAT3(0, -9.8f, 0), 0 }로 값을 채웁니다. 세 번째 {}pad0[2]를 0으로 채우는 부분입니다. updateConstantBuffer(...)는 앞 절에서 만든 Map/Unmap 헬퍼로, 이 32바이트를 GPU의 simCB로 올립니다.

2.7 바인딩과 Dispatch

UINT groups = (COUNT + LOCAL_SIZE - 1) / LOCAL_SIZE; — 필요한 그룹 수를 올림 나눗셈으로 계산합니다. 정수 나눗셈은 내림이라, 그냥 COUNT/LOCAL_SIZE100000/64 = 1562로 마지막 32개 파티클을 빠뜨립니다. + LOCAL_SIZE - 1을 더한 뒤 나누면 (100000 + 63)/64 = 100063/64 = 1563이 되어 모두 덮습니다. 이것이 ceil(a/b)를 정수만으로 계산하는 관용구입니다.

context->Dispatch(groups, 1, 1) — 드디어 실행. X방향 1563그룹, Y·Z 1그룹씩 띄웁니다. 총 스레드 = 1563 × 64 = 100,032개. 이 중 100,000개가 실제 파티클을 처리하고, 남는 32개는 셰이더의 if (i >= uCount) return;으로 즉시 종료됩니다. 셰이더의 numthreads와 여기 Dispatch의 곱이 전체 격자 크기라는 점을 다시 확인하세요.

2.8 UAV 바인딩 해제 — 잊으면 안 되는 마무리

ID3D11UnorderedAccessView* nullUAV = nullptr;널 포인터를 만들고, CSSetUnorderedAccessViews(0, 1, &nullUAV, &initCounts)u0 슬롯을 비웁니다. 방금 파티클을 갱신한 그 버퍼를, 다음 단계에서 정점 버퍼나 SRV로 읽어 화면에 점으로 그리려면 UAV 바인딩이 남아 있으면 안 되기 때문입니다.

함정 D3D11에는 OpenGL의 glMemoryBarrier 같은 명시적 배리어를 직접 호출하지 않습니다. 대신 규칙이 단순합니다: 같은 리소스가 UAV로 묶인 채 다른 스테이지(정점 버퍼·SRV)에 바인딩되면 안 됩니다. 그래서 Dispatch 뒤 반드시 UAV를 nullptr로 해제한 다음 그 리소스를 읽기 뷰로 다시 바인딩하세요. 드라이버가 스테이지 간 의존성을 자동으로 직렬화해 줍니다(컴퓨트 쓰기가 끝난 뒤에 읽기가 시작되도록). 해제를 빠뜨리면 D3D 디버그 레이어가 경고를 뿜고 해당 바인딩을 무시해, 화면이 비거나 이전 프레임이 그대로 보입니다.

마지막 주석대로, 이후에는 (a) 같은 ssboSRV/정점 버퍼 뷰를 만들어 점으로 렌더링하거나, (b) D3D11_USAGE_STAGING 버퍼로 CopyResource한 뒤 Map으로 CPU에서 결과를 읽어올 수 있습니다. STAGING 경로는 GPU→CPU 복사라 느리므로, 시각화가 목적이면 (a)가 정석입니다.

3. 흐름 · D3D11 API 대응 한눈에

지금까지의 단계와 그것을 담당하는 D3D11 API를 1:1로 정리합니다. 컴퓨트 파이프라인은 이 표만 외워도 절반은 끝납니다.

단계D3D11 API / HLSL역할
버퍼 생성ID3D11Device::CreateBuffer + MISC_BUFFER_STRUCTUREDGPU에 파티클 배열 할당·업로드
쓰기 뷰 생성CreateUnorderedAccessView셰이더 register(u0)에 연결할 UAV
셰이더 컴파일D3DCompile(..., "cs_5_0") + CreateComputeShaderHLSL → GPU 바이트코드 → 셰이더 객체
상수 업로드Map/Unmap (헬퍼)dt·중력·개수를 cbuffer b0
바인딩CSSetShader / CSSetConstantBuffers / CSSetUnorderedAccessViews셰이더·상수·UAV를 CS 스테이지에 장착
실행Dispatch(groups,1,1)[numthreads(64,1,1)]그룹 수 × 그룹 크기 = 전체 스레드
동기화UAV를 nullptr로 재바인딩읽기 전 의존성 해제(암묵적 배리어)
결과 사용SRV/VB로 렌더 또는 STAGING + CopyResource + Map화면 표시 또는 CPU 회수
주의 HLSL의 구조화 버퍼는 C++ 구조체와 바이트 단위로 정확히 일치해야 합니다. 특히 cbuffer의 float3은 16바이트 경계에서 정렬되므로, 예제처럼 float4를 쓰거나 명시적 _pad 멤버로 채워 C++ structStructureByteStride가 어긋나는 함정을 피하세요. 이 한 가지 규약이 컴퓨트 셰이더 디버깅의 대부분을 차지합니다.