Unity ComputeShader-컴퓨트 쉐이더
컴퓨트 쉐이더(Compute Shader)는 GPU의 병렬 처리 능력을 이용하여 복잡한 수학 연산을 수행하도록 설계된 프로그램입니다. 전통적인 그래픽스 파이프라인과는 별개로 작동하여, GPU의 계산 능력을 활용해 물리 시뮬레이션, 이미지 처리, 복잡한 수학 연산 등을 비교적 빠르게 처리할 수 있습니다.
Unity에서 컴퓨트 쉐이더는 ‘ComputeShader’라는 클래스를 통해 구현되며, HLSL(High-Level Shader Language)을 사용하여 작성됩니다. 사용자는 쉐이더 내부에서 ‘커널’이라고 불리는 함수들을 정의하고, 이 커널을 통해 GPU에 특정 작업을 지시할 수 있습니다. 각 커널은 독립적으로 수천 개의 스레드에서 동시에 실행될 수 있어, 대규모 데이터 세트에 대한 빠른 처리가 가능합니다.
컴퓨트 쉐이더는 하나이상의 커널로 이루어 집니다.유니티의 컴퓨트 쉐이더에서 “커널”이란, GPU에서 병렬로 실행될 수 있는 함수 또는 작업 단위를 의미합니다. 컴퓨트 쉐이더는 일반적으로 GPU의 계산 능력을 활용하여 대량의 데이터 처리를 수행하는 데 사용되며, 커널은 이러한 계산 작업을 구체적으로 수행하는 코드 블록입니다.
유니티의 컴퓨트 쉐이더에서 “쓰레드(thread)”는 컴퓨트 쉐이더가 수행하는 개별 작업 단위를 말합니다. GPU는 이러한 쓰레드들을 동시에 수많은 개수로 실행할 수 있어, 병렬 데이터 처리가 가능합니다. 즉, 각 쓰레드는 독립적으로 작은 작업을 수행하며, 모든 쓰레드가 모여 전체 계산 작업을 빠르게 완료합니다.
그럼 이제 가장 기본 코드를 분석해보겠습니다.
//이 줄은 CSMain이라는 커널 함수를 컴파일해야 함을 유니티에 알리는 지시어입니다.
//커널은 컴퓨트 쉐이더에서 병렬 실행될 함수를 의미합니다.
//모든 커널에는 프라그마가 필요합니다.
#pragma kernel CSMain
//RWTexture2D<float4>는 읽기와 쓰기가 가능한 2D 텍스처를 선언합니다.
//여기서 float4는 텍스처의 각 픽셀이 R, G, B, A 네 가지 부동소수점 값을 갖는 것을 의미합니다.
//이 텍스처는 결과 데이터를 저장하는 데 사용됩니다.
RWTexture2D<float4> Result;
//numthreads(8,8,1)는 각 디스패치 단위에서 쉐이더가 실행될 때 쓰레드 그리드의 크기를
//x, y, z 축에 대해 각각 8, 8, 1로 설정합니다.
//이 설정은 총 64(8x8)개의 쓰레드가 동시에 실행될 것임을 의미합니다.
[numthreads(8,8,1)]
//uint3 id 에서 id는 변수 이름
//SV_DispatchThreadID 는 변수에 할당되는 데이터 종류를 설명하는 시맨틱입니다.
void CSMain (uint3 id : SV_DispatchThreadID)
{
// TODO: insert actual code here!
//함수 내부에서는 Result 텍스처의 id.xy 위치에 색상 값을 저장합니다.
//여기서 id.x & id.y는 x와 y 좌표의 비트 AND 연산 결과,
//(id.x & 15) / 15.0와 (id.y & 15) / 15.0는 x와 y 좌표를 15로 비트 AND 연산한
//결과를 15.0으로 나눈 값입니다.
//이렇게 계산된 값들은 각각 색상의 R, G, B 채널 값을 결정하며,
//a(알파) 채널은 0.0으로 설정됩니다.
Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
}
“디스패치 단위란?"
컴퓨트 쉐이더에서 “디스패치 단위”라는 말은 컴퓨트 쉐이더 함수를 실행시키는 작업의 단위를 의미합니다. 간단하게 말해서, GPU에 어떤 작업을 시키기 위해 명령을 내리는 것을 ‘디스패치(dispatch)’라고 하며, 이때 각각의 명령이 실행되는 작업의 단위를 ‘디스패치 단위’라고 부릅니다.
쉐이더의 실행을 쉽게 이해하기
컴퓨터에 이미지를 처리하라고 명령을 내리는 것을 상상해 보세요. 이미지가 매우 크다면, 한 번에 모든 부분을 처리하기보다는 여러 조각으로 나누어 처리할 것입니다. 각 조각을 동시에 처리할 수 있도록 여러 작은 명령으로 나누어 GPU에 전달하는데, 이 작은 명령 하나하나가 바로 ‘디스패치 단위’라고 볼 수 있습니다.
이러한 디스패치 단위에는 몇 개의 쓰레드가 사용될지 지정해야 합니다. numthreads(8,8,1)
는 이 디스패치 단위에서 64개의 쓰레드(각각의 작은 명령을 수행하는 일꾼)가 동시에 작업을 수행하도록 설정합니다. 여기서 x, y, z 축을 사용하는 이유는 3차원 공간에서 작업을 분배하기 위함입니다(비록 여기서 z는 1이므로 실제로는 2차원적으로만 사용됩니다).
위의 컴퓨트 쉐이더를 실행하는 c#코드
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class AssignTexture : MonoBehaviour
{
public ComputeShader shader;
public int texResolution = 256;
Renderer rend;
RenderTexture outputTexture;
int kernelHandle;
// Start is called before the first frame update
void Start()
{
outputTexture = new RenderTexture(texResolution, texResolution, 0);
outputTexture.enableRandomWrite = true;
outputTexture.Create();
rend = GetComponent<Renderer>();
rend.enabled = true;
InitShader();
}
private void InitShader()
{
kernelHandle = shader.FindKernel("CSMain");
shader.SetTexture(kernelHandle, "Result", outputTexture);
rend.material.SetTexture("_MainTex", outputTexture);
DispatchShader(texResolution/16, texResolution/16);//16,16 = 256
}
private void DispatchShader(int x, int y)
{
// 여기서 x, y, 1 은 쓰레드그룹을 말한다.
shader.Dispatch(kernelHandle, x, y, 1);
}
// Update is called once per frame
void Update()
{
if(Input.GetKeyUp(KeyCode.U))
{
DispatchShader(texResolution/8, texResolution/8);//32,32 = 1024
}
}
}
결과
U를 누르면 오른쪽과 같이 화면에 전부 그림이 채워진다.
텍스쳐가 256x256 픽셀이고 쉐이더 쪽에서numthreads(8, 8, 1)
이라고 설정되었고, 디스패치 쪽에서 쓰레드 그룹이 shader.Dispatch(kernelHandle, texResolution/16, texResolution/16, 1);
이라고 설정되어 8 x 8 x 256(16x16) = 16384 가되어 텍스쳐의 일부에만 컬러가 반영
u 를 눌렀을때 8 x 8 x 1024(32x32) = 65536 이되어 전체에 컬러반영 됨.
텍스쳐 전체의 픽셀수는 256x256 = 65536 임.
의문
왜 쓰레드와 쓰레드 그룹은 각각 쉐이더와, c#에서 설정해서 사용할까?
이 구분은 쉐이더의 유연성과 효율성 때문입니다. 쉐이더 코드 내에서 쓰레드 그룹의 크기를 정의함으로써 개발자는 쉐이더가 어떤 종류의 GPU에서도 동일하게 작동하도록 할 수 있습니다. 그리고, Unity의 C# 코드 내에서 Dispatch
호출을 통해 실행될 그룹의 수를 동적으로 조절할 수 있으므로, 다양한 크기의 데이터를 처리할 수 있는 유연성을 갖추게 됩니다.
이렇게 분리함으로써 쉐이더의 실행은 두 단계로 관리됩니다:
- 쉐이더에서는 실행될 때 각 그룹 내에서 몇 개의 쓰레드를 사용할지 정의합니다.
- 애플리케이션 코드(C#)에서는 전체적으로 몇 개의 그룹이 필요한지 결정하여 쉐이더를 실행시킵니다.
이러한 분리는 개발자에게 처리해야 할 데이터의 양에 따라 최적의 쓰레드와 쓰레드 그룹의 수를 조정할 수 있는 유연성을 제공합니다.
그렇다고 합니다.