Compute Shader
컴퓨트 셰이더는 일반 셰이더와 목적을 달리한다.
vertex shader 또는 pixel shader와 같이 렌더링 파이프라인의 특정 과정중에 속하는 셰이더가 아닌,
GPU를 범용적으로 활용하여 특정 계산을 병렬 수행하는데에 특화된 셰이더이다.
그렇다면 구체적으로 병렬 연산이라 함은 어떤 것인가?
int sizeX = 10000, int sizeY = 10000, int sizeZ = 10000;
float[,,] arr = new float[sizeX, sizeY, sizeZ];
for(int i = 0; i < sizeX; i++)
for(int j = 0; j < sizeY; j++)
for(int k = 0; k < sizeZ; k++)
{
arr[i, j, k] = (i + j) * 1.0f / k;
}
위는 3중 for문으로 이루어진 시간복잡도 O(n^3)을 자랑하는 무거운 코드이다.
하지만, 실질적으로 arr에 각각 저장되는 값들을 보면 독립적으로 계산이 가능한 값이다.
이 값들을 각각 병렬적으로 연산 가능하다면 이론적으로 O(1)만에 끝낼 수 있다.
GPU는 이러한 병렬 연산을 수행할 수 있도록 하는 무수히 많은 코어를 탑재하고 있다.
Compute Shader는 위와 같은 연산을 쉽게 GPU를 활용하여 수행할 수 있도록 해준다.
Unity에서 이 Compute Shader를 지원한다.
(Project View - 마우스 우클릭 - Create - Shader - Compute Shader)
Unity에서 Compute Shader를 생성하면 기본적으로 다음과 같은 코드가 적혀있다.
#pragma kernel CSMain
RWTexture2D<float4> Result;
[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
}
처음 이 코드를 만나게 되면 다소 당황스러울 수 있지만,
한줄씩 이해하고 사용하기 시작하면 정말 편하고 쉽게 느껴질 것이다.
.
.
.
#pragma kernel CSMain
다른 언어에서 볼수 있는 Main 함수, 즉 엔트리 포인트에 해당하는 함수를 컴퓨트 셰이더에서는 커널이라고 한다.
하나의 컴퓨트 셰이더 안에서 커널을 여러개 만들 수 있는데, 서로 다른 프로그램으로 간주해도 무방하다.
여러개의 커널을 구분하기 위해 ID를 부여하는데,
커널을 만든 후 그에 대응되는 전처리문을 위와 같이 작성해야 ID가 부여된다.
커널ID 사용 이유:
컴퓨트 셰이더에 작성된 커널은 UnityC# 스크립트(CPU)에서 호출되고, GPU에서 수행된다.
UnityC# 스크립트에서 커널을 호출하기 위해서는 커널 ID가 필요하다.
ComputeShader.FindKernel("커널이름")을 호출하여 커널ID를 받아올 수 있다.
RWTexture2D<float4> Result;
컴퓨트 셰이더 안에서는 전역변수를 선언할 수 있고, UnityC# 스크립트에서 값을 할당해주어야 한다.
RWTexture2D는 컴퓨트 셰이더 내에서 읽기, 쓰기가 가능한 Texture2D라는 의미이다.
Unity C# 스크립트의 RenderTexture와 대응된다.
float4는 HLSL에서 사용되는 자료형인데, Unity C# 스크립트의 Vector4와 대응된다.
텍스쳐는 각 픽셀마다 r, g, b, a 정보가 들어있으므로 이를 float4로 표현하는 것이다.
변수명에서 알 수 있듯이 Result는 이 컴퓨트 셰이더에서 결과 이미지로 사용되는 듯 하다.
Unity C# 스크립트에서 ComputeShader.SetTexture(커널ID, texture)로 지정할 수 있다.
신기한 점은 RenderTexture의 경우 태생이 GPU쪽 식구인 듯 하다.
컴퓨트 셰이더에 한번 연결 해 놓고 커널을 실행하면 텍스쳐가 변하는것을 바로 볼 수 있다.
[numthreads(8,8,1)]
커널에 붙는 헤더부분이다.
병렬처리시 사용할 스레드 수와 연관되어 있다.
8, 8, 1 값은 각각 특정 방향으로 스레드 그룹의 크기를 나타내고
사용되는 총 스레드 수는 8 * 8 * 1, 총 64개이다.
의미상으로
x 축으로 8개의 스레드,
y축으로 8개의 스레드,
z축으로 1개의 스레드를 사용한다고 해석할 수 있다.
이 스레드 개수는 커널 호출 시 잘 고려해야 하는 부분 중 하나이다.
UnityC# 스크립트에서 ComputeShader.Dispatch()함수로 컴퓨트 셰이더의 커널을 호출할 수 있다.
Dispatch()함수의 형식을 잠깐 보면,
void ComputeShader.Dispatch(int kernelIndex, int threadGroupsX, int threadGroupsY, int threadGroupsZ);
총 4개의 인자를 받는 것을 볼 수 있는데, 첫번째 인자로 호출할 함수의 ID,
나머지 인자로는 각각의 방향으로 하나의 스레드 당 얼만큼 일해야 하는지 알려주는 부분이다.
예를들어 256 x 256 크기의 이미지에 포함된 각각의 픽셀마다 연산을 수행한다고 가정하면,
shader.Dispatch(kernelID, 256 / 8, 256 / 8, 1);
이와같이 처리 데이터 크기(256, 256, 1)를 스레드 개수(8, 1, 1)로 나눈 값을 넘긴다.
그러면 총 (32 * 8) * (32 * 8) * 1 = 256 ^ 2번, 즉 픽셀 개수만큼 함수 호출이 된다.
동일한 방식으로 256 x 256 x 256 크기의 3차원 데이터를 처리할 경우에는
컴퓨트 셰이더에서 [numthreads(8, 8, 8)] 로 Z축의 스레드 개수를 맞춘 뒤
Unity C# 스크립트에서는 shader.Dispatch(kernelID, 256 / 8, 256 / 8, 256 / 8);를 호출한다.
그러면 총 (32 * 8) * (32 * 8) * (32 * 8) = 256 ^ 3번 ,즉 데이터 개수만큼 함수 호출이 된다.
void CSMain (uint3 id : SV_DispatchThreadID)
컴퓨트 셰이더의 엔트리 포인트에 해당하는 함수이다.( = 커널)
UnityC# 스크립트에서 앞서 설명한 ComputeShader.Dispatch()로 이 함수를 실행시키면,
지정된 수만큼 함수가 병렬적으로 수행된다.
이 때 수행되는 함수들 각각 uint3 형식의 고유한 id가 부여된다.
(uint3는 Vector3의 unsigned int 버전으로 생각하면 된다.)
id는 아래와 같이 다중 for문을 사용할 때 쓰이는 index와 동일한 역할을 한다.
(매우 중요!! 이 글의 핵심 포인트. 이것만 알고 가도 대충 어떻게 쓰이는지 이해할 수 있다.)
for(int i = 0; i < sizeX; i++)
for(int j = 0; j < sizeY; j++)
for(int k = 0; k < sizeZ; k++)
{
arr[i, j, k] = (i + j) * 1.0f / k;
}
즉 이미지 처리를 한다고 가정하면, 이 id는 현재 어떤 픽셀의 위치에서 이 함수를 수행하고 있는지 의미를 갖게 된다.
Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
Result는 앞서 설명했듯 텍스쳐 이미지이고, 특정 픽셀에 접근 시 인덱스로 uint2를 사용한다.
id.xy는 x와 y를 값으로 갖는 uint2 값을 의미한다. (셰이더에서 자주 사용하는 스위즐링 기법이다.)
따라서 Result[id.xy] = ? 형식으로 값을 대입 시 Result 이미지의 (x, y)에 어떤 값을 대입한다는 의미가 된다.
float4 형식의 데이터를 생성 할 때는 인자로 x, y, z, w(= r, g, b, a) 값을 각각 넘긴다.
픽셀 형식은 float4이므로 float4 데이터 타입으로 해당 픽셀에 어떠한 값을 할당하는 것으로 끝이 난다.
나머지 값의 의미는 크게 중요하지 않기도하고 해석하기도 피곤하니 아래에서 결과를 보도록 하자.
.
.
.
이제 실질적으로 화면에 이 컴퓨트 셰이더 연산의 결과를 보이게 하기 위해서는
Unity C#에서 텍스쳐를 셰이더에 할당하고, 실행시켜줘야 한다.
아래는 그 코드이다.
using UnityEngine;
using UnityEngine.UI;
public class ComputeShaderExample : MonoBehaviour
{
public ComputeShader shader;
public RawImage rawImage;
RenderTexture renderTexture;
void Start()
{
// 256 x 256 크기의 RenderTexture 생성.
renderTexture = new RenderTexture(256, 256, 32);
renderTexture.enableRandomWrite = true;
renderTexture.filterMode = FilterMode.Point;
renderTexture.Create();
// UI에 띄워져있는 rawImage에 renderTexture 연결
rawImage.texture = renderTexture;
// 컴퓨트셰이더의 함수ID 찾기
int kernelID = shader.FindKernel("CSMain");
// 컴퓨트셰이더에 renderTexture 연결
shader.SetTexture(kernelID, "Result", renderTexture);
// 컴퓨트셰이더 실행.
shader.Dispatch(kernelID, 256 / 8, 256 / 8, 1);
}
}
스크립트를 작성 후 게임오브젝트에 추가하고
처음에 만든 컴퓨트셰이더를 shader에 할당,
RawImage 하나를 캔버스에 만든 다음, rawImage에 할당해주면 된다.
그리고 바로 실행해 보면 아마 아무것도 안 보일 것이다.
그 이유는 컴퓨트 셰이더 코드에서 픽셀 색상 지정시 알파값이 0으로 되어있기 때문이다.
Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
가장 기본적으로 생성되는 코드인데 왜 알파값을 0으로 만들어 놓았는지 이해는 안가지만..
아무튼 가장 마지막의 0.0을 1로 수정 후 실행시켜보면 아래와 같은 결과를 볼 수 있다.
시어핀스키 삼각형같은 무엇인가 인 것 같다.
.
.
.
이대로 글을 끝내기에는 좀 심심할 것 같아서 Result 할당 부분을 수정해 보았다.
if (length(id.xy - 128.0) < 100)
Result[id.xy] = float4(1, 1, 1, 1);
else
Result[id.xy] = float4(0, 0, 0, 1);
넘겨 받은 이미지가 256 x 256이고, id는 (0,0) ~ (256, 256)이므로
이미지 중앙(128, 128)으로부터 거리가 100 미만인 픽셀은 흰 색,
그 이외의 픽셀은 검정색을 칠한다.
끝