티스토리 뷰
개요
이번 시간에는 DirectX12로 Compute Shader를 사용하는 예제를 만들어 보도록 하겠습니다.
먼저 단어 분석부터 해보겠습니다.
Compute Shader에서 Compute는 계산이라는 뜻이고, Shader는 컴퓨터 그래픽스의 Vertex Shader/Pixel Shader에서의 Shader입니다.
기존에 Shader들은 Vertex별로 실행되거나, Pixel별로 실행되는 등 특정한 역할이 있었습니다.
다만 컴퓨터 그래픽스 기술이 발전하고, GPU가 발전하면서 General한 연산을 GPU의 도움을 받아 진행하고 싶은 니즈가 생겼고, 이를 해결하기 위해 나온 것인 Compute Shader입니다.
Compute Shader는 일단 간단하게 "General한 연산을 GPU로 실행하는 프로그램" 정도로 이해하시면 될 것 같습니다.
이런 General한 목적으로 GPU를 사용하는 것을 GPGPU(General-Purpose computing on Graphics Processing Units)이라고 부르기도 합니다.
사실 GPU를 사용하는 기술들은 DirectX의 Compute Shader 말고도 있습니다. NVIDIA의 Cuda라는 기술도 있죠.
하지만 Compute Shader는 DirectX12 API의 함수들을 이용해서 GPU를 사용하는 것이기 때문에 기존 렌더링 파이프라인과 잘 연동이 되어 있습니다.
예를 들자면 DirectX12에서 사용하는 Graphics Resource에 바로 Compute Shader의 연산 결과를 담고 사용할 수 있습니다.
따라서 연기 시뮬레이션 등 렌더링과 관련된 병렬 처리가 무거운 작업들을 하기에 적합합니다.
CUDA를 이용해서도 위와 같은 작업이 가능하기는 하지만, CUDA를 사용하게 되면 코드가 복잡해질 수도 있습니다.
또한 CUDA는 NVIDIA 전용이기 때문에 AMD GPU에서 사용하는게 불가능하기도 합니다.
아무튼 이번 시간에는 DirectX12로 Compute Shader를 사용하는 예제를 만들어 보도록 하겠습니다.
예제
이번 시간에는 단순히 사용법에 대해서 알아보는 것이므로 아주 간단한 연산만 해보도록 하겠습니다.
바로 픽셀의 위치에 따라 색이 다른 아래와 같은 텍스쳐를 만드는 것입니다.

사실 이와 같은 작업은 기존 렌더링 파이프라인에서 Vertex Shader로 Color 값을 보내고 Pixel Shader에서 받도록 하면 중간에 Rasterizer가 자동으로 해주는 일이긴 합니다.
예를 들자면 Quad를 Draw하는 Pass를 하나 만들고, Vertex Shader와 Pixel Shader를 아래와 같이 구현하면 됩니다.
struct VS_INPUT
{
float3 Position : POSITION;
float3 Color : COLOR;
};
struct PS_INPUT
{
float4 Position : SV_Position;
float4 Color : COLOR;
};
PS_INPUT main(VS_INPUT IN)
{
PS_INPUT OUT;
OUT.Position = float4(IN.Position, 1.0f);
OUT.Color = float4(IN.Color, 1.0f);
return OUT;
}
VertexShader에서는 인자로 받은 Color 값을 그대로 Pixel Shader로 넘겨주고 있습니다.
예제에서는 Triangle을 2개 Rendering했고 각각 정점의 Color 값은 아래와 같이 설정했습니다.

struct PS_INPUT
{
float4 Position : SV_Position;
float4 Color : COLOR;
};
float4 main(PS_INPUT IN) : SV_TARGET
{
return IN.Color;
}
PixelShader를 위와 같이 구현하면 정점 사이에 있는 픽셀들의 Color 값은 자동으로 보간되어 적용됩니다.
따라서 가장 위에 보셨던 텍스쳐 색이 그려진 것이죠.
이제 이 텍스쳐를 Compute Shader를 통해서 그려보겠습니다.
Compute Shader 사용법에 대해서 간략하게 설명 드리기 위해 위와 같은 예제를 채택했습니다.
예제 요약
예제는 간략하게 다음과 같은 과정으로 진행됩니다.
1) 텍스쳐 리소스를 만들기
2) 텍스쳐 리소스에 값을 쓰고 읽을 수 있도록 UAV와 SRV로 연결하기
3) Compute PSO와 RootSignature를 만들기
4) Dispatch 함수를 통해 Compute Shader를 돌려서 Texture에 값을 채우기
5) Graphics PSO와 RootSignature를 만들기
6) Draw 함수를 통해 Vertex Shader와 Pixel Shader를 돌려서 Texture의 값을 읽어서 화면에 보여주기
예제 구현
Texture를 만들고 UAV(Unordered Access View), SRV(Shader Resource View)를 만드는 것은 어렵지 않습니다.
device의 CreateCommittedResource 함수를 호출해서 Texture Resource를 만들고
device의 CreateUnorderedAccessView 함수를 호출해서 UAV를 만들고
device의 CreateShaderResourceView 함수를 호출해서 SRV를 만들어서 방금 만든 Texture에 연결합니다.
UAV는 ComputeShader에서 사용하며 연결된 Texture에 값을 쓰는데 사용합니다.
SRV는 PixelShader에서 사용하며 연결된 Texture에 쓰인 값을 읽어 오는데 사용합니다.
Compute Shader를 사용하려면 Compute 전용 RootSignature와 PSO(PipelineStateObject)를 생성해야 합니다.
device의 CreateRootSignature 함수를 호출해서 RootSignature를 만들고
device의 CreateComputePipelineState 함수를 호출해서 PSO를 만듭니다.
PSO를 만들 때는 작성한 Compute Shader를 넘겨주면 됩니다.
그 후 실제로 Dispatch를 하기 전에는
commandList의 SetComputeRootSignature 함수를 호출하고
commandList의 SetComputeRootDescriptorTable등의 함수를 호출해서 Parameter를 설정합니다.
commandList의 SetPipelineState 함수로 아까 만든 PSO를 전달해서 셋팅을 마친 뒤 Dispatch 함수를 호출합니다.
Dispatch를 하는 과정은 Graphics Pipeline과 함수 몇 개만 다를 뿐 대체로 비슷한 과정을 거칩니다.
이미 Graphics Pipeline을 통해서 Draw를 하는 코드가 있다면 어렵지 않게 Compute Shader를 실행시킬 수 있을 겁니다.
Dispatch 함수는 아래와 같이 호출했으며
commandList->Dispatch(WindowWidth / 32, WindowHeight / 18, 1);
Compute Shader는 다음과 같이 정의하였습니다.
RWTexture2D<float4> OutTex : register(u0);
[numthreads(32, 18, 1)]
void main(uint3 DTid : SV_DispatchThreadID,
uint3 GTid : SV_GroupThreadID,
uint3 GID : SV_GroupID)
{
OutTex[DTid.xy] = float4((float)DTid.x / 1280.0f, (float)DTid.y / 720.0f, 0.0f, 1.0f);
}
Dispatch 함수가 호출되면 "ThreadGroup 개수 x ThreadGroup에 존재하는 Thread 개수"만큼 ComputeShader의 Main 함수가 호출됩니다.
Dispatch 함수를 호출할 때 보내는 인자 3개가 ThreadGroup 개수를 뜻하며, Compute Shader의 main 위에 있는 numthreads에 사용된 인자 3개가 "Thread Group에 존재하는 Thread 개수"를 뜻합니다.
자세한 내용은 이따 알아보도록 하겠습니다.
Compute Shader 코드 가장 상단에 보이는 RWTexture2D<float4> OutTex : register(u0)는 UAV에 연결된 Texture를 선언한 부분입니다.
OutTex라는 이름으로 선언을 했고, main 함수 내부에서 OutTex[DTid.xy] = ... 구문을 통해 텍스쳐의 각 픽셀에 값을 썼습니다.
Compute Shader 함수 내부를 파악하기에 앞서 OutTex Texture에 어떤 값이 써졌는지 한 번 가시적으로 확인해보겠습니다.
Dispatch를 호출한 이후 해당 Texture을 Input으로 해서 FullScreenQuad를 Draw하면 OutTex에 어떤 값이 써져있는지 확인할 수 있습니다.
Texture2D gTex : register(t0);
struct PS_INPUT
{
float4 Position : SV_Position;
float4 Color : COLOR;
};
float4 main(PS_INPUT IN) : SV_TARGET
{
float4 sampledColor = gTex.Load(int3(IN.Position.x, IN.Position.y, 0));
return sampledColor;
}
FullScreenQuad를 Draw하는 PixelShader는 위와 같이 구현하였습니다.
단순히 연결된 Texture로부터 값을 읽는 것 외에 아무것도 하지 않았습니다.
이렇게 Draw한 결과는 포스팅 처음에 보셨던 이미지입니다.

텍스쳐에 어떤 값이 써졌는지 색으로 확인했으므로 이제 다시 돌아가서 아까 보았던 Compute Shader 내부 코드를 다시 살펴보도록 하겠습니다.
RWTexture2D<float4> OutTex : register(u0);
[numthreads(32, 18, 1)]
void main(uint3 DTid : SV_DispatchThreadID,
uint3 GTid : SV_GroupThreadID,
uint3 GID : SV_GroupID)
{
OutTex[DTid.xy] = float4((float)DTid.x / 1280.0f, (float)DTid.y / 720.0f, 0.0f, 1.0f);
}
처음보는 예약어 numthreads가 있는데요
이해하기 위해서는 일단 ThreadGroup이라는 개념을 알아야 합니다.
ThreadGroup
ThreadGroup이란 말 그대로 여러 개의 Thread를 묶은 것을 뜻합니다.
하나의 ThreadGroup에는 최대 1024개의 Thread를 묶을 수 있습니다.
Compute Shader 코드에서 [numthreads(32, 18, 1)] 부분이 바로 하나의 ThreadGroup에 존재하는 Thread 개수입니다.
ThreadGroup에 있는 Thread들은 1차원 배열로 표현할 수 있지만, 2차원이나, 3차원 배열로 표현할 수도 있습니다.
예를 들어 총 1024개의 Thread를 1024 * 1 * 1로 표현할 수도 있고, 32 * 32 * 1이나, 32 * 16 * 2로 표현할 수도 있습니다.
Dispatch 함수에 인자로 들어가는 변수는 ThreadGroup의 개수입니다.
예제에서는 (WindowWidth / 32, WindowHeight / 18, 1) 값을 사용했습니다.
Window의 크기는 1280, 720으로 각각 32와 18로 나누었을 때 40이 됩니다.
ThreadGroup 또한 3차원 배열로 표현할 수 있습니다.
ThreadGroup이 총 64개 있다고 했을 때, 64 * 1 * 1로 표현할 수도 있고, 32 * 2 * 1이나, 16 * 2 * 2로 표현할 수도 있습니다.
Dispatch 함수가 호출되면 "ThreadGroup 개수 * ThreadGroup에 존재하는 Thread 개수"만큼 ComputeShader의 Main 함수가 호출됩니다.
위 예제에서는 ThreadGroup 개수는 40 * 40개이고, ThreadGroup에 존재하는 Thread 개수는 32 * 18개 이므로
40 * 40 * 32 * 18 = 921,600번 호출되게 됩니다.
ThreadGroup이라는 개념이 생긴 이유는 하드웨어 효율을 높이기 위해서라고 합니다.
GPU의 실행 단위인 Wavefront/Warps(AMD의 Wavefront는 64개, NVIDIA의 Warp는 32개) 개수에 맞춰서 Thread를 묶으면 SIMD 구조를 최대한 활용할 수 있기 때문이라고 합니다.
ThreadGroup에 들어가는 Thread 개수가 최대 1024개인 이유도 이와 관련이 있습니다.
그 외에 ThreadGroup들과 그 안에 Thread들을 표현할 때 3차원 데이터로 표현하는 이유는, 그래픽스에서 2차원 혹은 3차원 데이터를 다룰 일이 많기 때문입니다. 2차원으로 표현하게 되면 Texture 같은 데이터를 다룰 때 훨씬 간편하게 다룰 수 있습니다.
DispatchThreadID, GroupThreadID, GroupID
다시 예제 코드로 돌아가서 SV_DispatchThreadID, SV_GroupThreadID, SV_GroupID에 대해서 알아보겠습니다.
변수들을 짧게 정의하면 아래와 같습니다.
SV_DispatchThreadID: 모든 쓰레드 그룹의 쓰레드들 사이에서 하나의 쓰레드에 유일하게 정의된 ID입니다.
SV_GroupThreadID: 하나의 쓰레드 그룹의 쓰레드들 사이에서 하나의 쓰레드에 유일하게 정의된 ID입니다. 다른 쓰레드 그룹에 같은 SV_GroupThreadID 값을 가진 쓰레드가 있습니다.
SV_GroupID: 모든 쓰레드 그룹 내에서 하나의 그룹에 유일하게 정의된 ID입니다, 그 내부의 모든 쓰레드는 같은 SV_GroupID 값을 가집니다.
이 3개의 변수에 대한 관계식은 아래와 같습니다.
SV_DispatchThreadID.x = SV_GroupID.x * numthreads.x + SV_GroupThreadID.x
y축이나 z축도 똑같이 계산이 됩니다.
SV_DispatchThreadID.y = SV_GroupID.y * numthreads.y + SV_GroupThreadID.y
SV_DispatchThreadID.z = SV_GroupID.z * numthreads.z + SV_GroupThreadID.z
따라서 사실 SV_GroupThreadID 값과 SV_GroupID 값, numthreads 값만 있으면 SV_DispatchThreadID 값을 유추할 수 있습니다.
각 변수의 의미를 좀 더 파악하기 위해 아래와 같은 예제를 만들었습니다.
RWTexture2D<float4> OutTex : register(u0);
[numthreads(32, 18, 1)]
void main(uint3 DTid : SV_DispatchThreadID,
uint3 GTid : SV_GroupThreadID,
uint3 GID : SV_GroupID)
{
OutTex[DTid.xy] = float4((float)GTid.x / 32.0f, (float)GTid.y / 18.0f, 0.0f, 1.0f);
}
먼저 등호 왼쪽에 있는 OutTex[DTid.xy]의 뜻을 해석해보겠습니다.
OutTex는 RWTexture2D 타입이므로 인덱스를 2차원 형태로 받습니다.
DTid.xy(SV_DispatchThreadID) 값은 모든 그룹의 모든 쓰레드 사이에서 유일하게 정의된다고 했습니다.
commandList->Dispatch(WindowWidth / 32, WindowHeight / 18, 1);
위와 같은 형태로 Dispath 함수를 호출하고 WindowWidth가 1280, WindowHeight가 720이므로 쓰레드 그룹의 갯수는 X축 40개, Y축 40개가 됩니다.
[numthreads(32, 18, 1)]
위와 같은 형태로 ComputeShader를 정의했으므로 한 그룹당 쓰레드 개수는 X축 32개, Y축 18개입니다.
X축 총 쓰레드 개수는 1280개이고, Y축 총 쓰레드 개수는 720개 입니다.
따라서 DTid.xy는 화면의 모든 픽셀을 1대 1로 대응하게 됩니다.
등호 오른쪽을 보면 GTid.x를 32로 나눈 값과, GTid.y를 18로 나눈 값을 각각 x, y 값으로 넘겼습니다.
GTid.x를 32로 나눈 이유는 GTid.x 값의 범위가 0~31이기 때문입니다.
GTid.y를 18로 나눈 이유 역시 GTid.y 값의 범위가 0~17이기 때문입니다.
GTid(GroupThreadID)는 하나의 쓰레드 그룹의 쓰레드들 사이에서 유일하게 정의된 쓰레드라고 했습니다.
즉, 다른 나머지 쓰레드 그룹에도 같은 ID를 가진 쓰레드가 있다는 말입니다.
결론적으로 GTid를 이용해서 값을 채우게 되면 모든 쓰레드 그룹 각각이 담당하는 픽셀에 같은 값이 채워지게 됩니다.
따라서 아래와 같은 이미지가 나오게 됩니다.

GTid를 사용했으므로 마지막으로 GID도 사용해보도록 하겠습니다.
아래와 같이 Compute Shader를 정의했습니다.
RWTexture2D<float4> OutTex : register(u0);
[numthreads(32, 18, 1)]
void main(uint3 DTid : SV_DispatchThreadID,
uint3 GTid : SV_GroupThreadID,
uint3 GID : SV_GroupID)
{
OutTex[DTid.xy] = float4((float)GID.x / 40.0f, (float)GID.y / 40.0f, 0.0f, 1.0f);
}
이 예제도 아까 예제와 사실 큰 차이는 없습니다.
등호 우변을 GTid 대신 GID를 사용했을 뿐입니다.
GID는 그룹 마다 다른 값을 가지므로 아래와 같은 이미지가 나오게 됩니다.

결론
지금까지 Compute Shader를 사용하는 방법에 대해서 알아보았습니다.
기존 렌더링 파이프라인을 구성하는 것에서 살짝씩만 변경하면 되기 때문에 사용하는데 크게 어려움은 없을 것이라고 생각합니다.
Compute Shader를 정의할 때 인덱스를 다루는 부분이 어렵기는 하지만 제대로 활용하면 막강한 GPU 자원을 이용해서 많은 일을 할 수 있을 것이라고 생각합니다.
'프로그래밍 > DirectX12' 카테고리의 다른 글
| DirectX12를 활용한 Deferred Shading (0) | 2025.07.22 |
|---|---|
| DirectX12를 활용한 Bloom 효과 (0) | 2025.03.30 |
| DirectX12를 활용한 Instanced Drawing (0) | 2025.03.03 |
| DirectX12에서의 CPU & GPU 동기화 (1) | 2025.01.26 |
| Microsoft PIX를 활용한 DirectX12 프로그램 디버깅 (0) | 2025.01.13 |
- Total
- Today
- Yesterday
- 유니티
- perspective projection
- collision detection
- 참조 형식
- 중복 조합
- 통계학
- Mesh Processing
- Unity
- RubiksCube
- DirectX12
- AABB
- 경우의 수
- 루빅스큐브
- 순열
- MeshProcessing
- VTK
- C#
- 값 형식
- normalized device coordinate
- Mesh
- 수학
- value type
- RL
- 최적화
- CollisionDetection
- Scriptable Render Pipeline
- opengl
- Unreal
- 중복 순열
- 조합
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |