개인 학습 및 정보 공유 차원에서 Unity에서의 DirectCompute(compute shader를 사용한) 튜토리얼 시리즈 번역을 할 예정입니다. 원작자에게 허가를 받은 사항입니다.
현재 6편이 포스팅되어 있으며 가능한 하루에 하나씩 번역해 올릴 생각입니다.
원문은 ScrawkBlog (http://scrawkblog.com/)에서 보실 수 있습니다. 좋은 자료가 많이 있으니 관심 있으신 분들은 한번 들어가 보세요.
(*제가 추가한 부분은 역주 표시가 되어있으며, 일부 한/영 번역이 일관성이 부족할 수 있습니다.)
---------------------------------------------------------------------------------------------------
Unity를 위한 DirectCompute 튜토리얼 : 커널과 쓰레드 그룹
- From ScrawkBlog (http://scrawkblog.com/)지난번 포스트는 약간의 개요였지만 여기서부터는 코드와 관련된 내용입니다. 오늘은 Unity에서 compute shader의 작성을 위한 핵심 개념을 다룰 것입니다. Compute shader의 핵심은 커널입니다. 이것은 셰이더로의 진입점이며, 다른 프로그래밍 언어에서의 메인 함수처럼 동작합니다. 또한 GPU의 쓰레드 tiling에 대해서도 다룰 것입니다. 타일들은 블록 또는 쓰레드 그룹이라고도 합니다.
Unity에서 compute shader를 생성하기 위해서는 프로젝트 패널로 가서 create->compute shader를 클릭하기만 하면 되고, Monodevelop에서 수정하기 위해서는 더블 클릭해서 열면 됩니다. 아래 코드를 새로 생성된 compute shader에 붙여넣으세요.
#pragma kernel CSMain1 [numthreads(4,1,1)] void CSMain1() { }
이는 compute shader의 최소한의 코드이고, 물론 아무 것도 수행하지 않지만 시작을 위한 코드로는 좋습니다. Compute shader는 Unity 스크립트에서 실행되어야 하고 따라서 스크립트도 필요합니다. 프로젝트 패널로 가서 Create->C# script를 클릭하세요. KernelExample로 이름을 설정하고 아래 코드를 붙여넣으세요.
using UnityEngine; using System.Collections; public class KernelExample : MonoBehaviour { public ComputeShader shader; void Start () { shader.Dispatch(0, 1, 1, 1); } }
이제 스크립트를 아무 게임 오브젝트에 붙이시고 compute shader를 shader 속성에 붙여넣으세요. 이제 씬이 실행될 때, Start 함수에서 compute shader가 실행될 것입니다. 씬을 실행하기 전에 dx11을 활성화하기위해 Edit->Project Settings->Player로 가서 “Use Direct3D 11”의 체크박스를 설정하세요.(역주 : Unity 4.6 및 Unity 5.1버전에서 저 항목이 없습니다. 디폴트 설정으로 놔둬도 잘 실행되었습니다.) 이제 씬을 실행해 보십시오. 셰이더는 아무것도 하지 않겠지만 에러도 없어야 합니다.
스크립트에서, “Dispatch” 함수가 호출되는 것을 보실 수 있을겁니다. 이 함수가 셰이더를 동작하게 하는 함수입니다. 첫 번째 변수가 0인 것을 주목하세요. 이것은 실행을 원하는 커널의 아이디입니다. 셰이더에서는 “#pragma kernel CSMain1”을 보실 수 있을겁니다. 이는 셰이더 내의 여러 함수(또는 여러 커널들도 있을 수 있습니다)들 중 어떤 함수가 커널인지 정의하는 것입니다. 셰이더 내에 CSMain1이라는 이름의 함수가 있어야만 하고 그렇지 않으면 셰이더는 컴파일되지 않을것입니다.
이제 “[numthreads(4,1,1)]” 줄을 주목하세요. 이는 GPU에게 그룹당 몇 개의 쓰레드를 커널에 대해 실행할 것인지 알려주는 부분입니다. 세 개의 숫자는 각 차원에 대한 숫자입니다. 쓰레드 그룹은 3차원으로 구성될 수 있고, 이 예제에서는 4개 쓰레드의 너비를 가진 1차원의 그룹을 실행합니다. 그 말은, 총 4개의 쓰레드가 실행되며 각 쓰레드는 커널의 복사본을 실행할 것입니다. 이것이 GPU가 빠른 이유입니다. GPU는 동시에 몇 천 개의 쓰레드를 동시에 실행 가능합니다.
이제 커널이 실제로 뭔가를 하게 해봅시다. 셰이더를 이렇게 바꿔보세요…
#pragma kernel CSMain1 RWStructuredBuffer<int> buffer1; [numthreads(4,1,1)] void CSMain1(int3 threadID : SV_GroupThreadID) { buffer1[threadID.x] = threadID.x; }
그리고 스크립트의 Start 함수를 아래처럼 바꿔보세요…
void Start () { ComputeBuffer buffer = new ComputeBuffer(4, sizeof(int)); shader.SetBuffer(0, "buffer1", buffer); shader.Dispatch(0, 1, 1, 1); int[] data = new int[4]; buffer.GetData(data); for(int i = 0; i < 4; i++) Debug.Log(data[i]); buffer.Release(); }
이제 씬을 실행하면 숫자 0,1,2,3이 출력되는 것을 보실 수 있을겁니다. 버퍼(buffer)에 대해서는 아직은 너무 걱정하지 마세요. 버퍼에 대해서는 제가 나중에 자세히 다룰 것이고 그냥 버퍼는 데이터를 저장하는 장소이고, 처리가 끝나면 릴리즈(release) 함수를 호출해줘야 한다는 것만 알아두세요. CSMain1 함수의 인수(argument)로 추가된 “int3 threadID : SV_GroupThreadID”를 주목하세요. 이것은 커널이 실행될 때 GPU에 해당 커널의 쓰레드 아이디를 전달하도록 요청하는 것입니다. 그리고 우리는 버퍼에 쓰레드 아이디를 쓰는데, 앞서 말했듯이 GPU는 4개의 쓰레드를 실행하도록 했기 때문에 출력된 것처럼 0에서 3까지의 아이디를 갖는 쓰레드가 실행되는 것입니다.
저 4개의 쓰레드가 쓰레드 그룹을 구성합니다. 이 예제에서는 4개의 쓰레드를 갖는 1개의 그룹을 실행하지만, 여러 그룹을 실행할 수도 있습니다. 이제 1개 말고 2개의 그룹을 실행 해봅시다. 셰이더 커널을 아래와 같이 바꾸세요.
void CSMain1(int3 threadID : SV_GroupThreadID, int3 groupID : SV_GroupID) { buffer1[threadID.x + groupID.x*4] = threadID.x; }
그리고 스크립트의 Start 함수를 아래와 같이 바꾸세요.
void Start () { ComputeBuffer buffer = new ComputeBuffer(4 * 2, sizeof(int)); shader.SetBuffer(0, "buffer1", buffer); shader.Dispatch(0, 2, 1, 1); int[] data = new int[4 * 2]; buffer.GetData(data); for(int i = 0; i < 4 * 2; i++) Debug.Log(data[i]); buffer.Release(); }
이제 씬을 실행하면 0-3이 두 번 출력되어야 합니다. dispatch함수의 변화에 주목하세요. 뒤쪽 세 개의 인수(2,1,1)는 실행하고 싶은 그룹의 숫자이고 쓰레드처럼 그룹도 3차원까지 설정이 가능하며, 이 예제에서는 1차원의 2개 그룹을 실행하고 있습니다. 또한 커널의 인수를 “int3 groupID : SV_GroupID”를 추가하여 수정하였습니다. 이것은 커널이 실행될 때 GPU에 그룹 아이디를 전달하도록 요청하는 것입니다. 이것이 필요한 이유는 우리가 각 4개 쓰레드를 갖는 2개 그룹에서 8개 값을 출력하려 하기 때문입니다. 버퍼에서 쓰레드의 위치가 필요하고 이에 대한 수식은 쓰레드 아이디에 그룹 아이디 곱하기 쓰레드 숫자입니다(threadID.x + groupID.x*4). 이렇게 쓰는 것은 좀 어색합니다. 당연히 GPU가 쓰레드 위치를 알아야 하지 않을까요? 맞습니다. 셰이더 커널을 아래와 같이 바꾸고 다시 씬을 실행해보세요.
void CSMain1(int3 threadID : SV_GroupThreadID, int3 dispatchID : SV_DispatchThreadID) { buffer1[dispatchID.x] = threadID.x; }
똑같이 0-3이 두 번 출력됩니다. 그룹 아이디 인수가 “int3 dispatchID : SV_DispatchThreadID”로 바뀐 것에 주목하세요. 이것은 위 수식과 같은 숫자를 넘겨주지만 GPU가 그 계산을 대신 해 주는 것입니다. 이것이 쓰레드 그룹들이 있을 때 쓰레드의 위치입니다.
지금까지는 1차원만 다루었습니다. 이제 다음 단계인 2차원으로 가 봅시다. 이번엔 커널을 다시 작성하는 대신 다른 커널을 셰이더에 추가해봅시다. 같은 알고리즘을 수행하기 위해 각 차원에 따라 서로 다른 커널을 사용하는 것이 그리 특이한 것은 아닙니다. 먼저 아래 코드를 이전 코드 아래에 추가하면 이제 셰이더에는 두 개의 커널이 있게 됩니다.
#pragma kernel CSMain2 RWStructuredBuffer<int> buffer2; [numthreads(4,4,1)] void CSMain2( int3 dispatchID : SV_DispatchThreadID) { int id = dispatchID.x + dispatchID.y * 8; buffer2[id] = id; }
그리고 스크립트는 아래와 같습니다.
void Start () { ComputeBuffer buffer = new ComputeBuffer (4 * 4 * 2 * 2, sizeof(int)); int kernel = shader.FindKernel ("CSMain2"); shader.SetBuffer (kernel, "buffer2", buffer); shader.Dispatch (kernel, 2, 2, 1); int[] data = new int[4 * 4 * 2 * 2]; buffer.GetData (data); for(int i = 0; i < 8; i++) { string line = ""; for(int j = 0; j < 8; j++) { line += " " + data[j+i*8]; } Debug.Log (line); } buffer.Release (); }
씬을 실행하면 0에서 7까지 출력되는 행과 8에서 15까지 출력되는 행, 계속해서 같은 방식으로 63까지 출력되는 것을 볼 수 있습니다. 왜 0에서 63까지일까요? 이제 4개의 2차원 쓰레드 그룹이 있고, 각 그룹은 4x4이니, 16개의 쓰레드 입니다. 결국 총 64개의 쓰레드가 되겠죠. 출력되는 값을 정의하는 “int id=dispatchID.x + dispatchID.y*8” 부분을 주목하세요. Dispatch 아이디는 그룹에서의 쓰레드의 위치입니다. 이재 2차원이니 버퍼에서는 쓰레드의 전역 위치가 필요하고, 이는 dispatch의 x아이디 더아기 dispatch의 y아이디 곱하기 1차원에서의 총 쓰레드 개수(4*2)입니다. Compute shader를 사용하기 위해서는 이 개념에 익숙해져야 합니다. 그 이유는 버퍼가 항상 1차원이기 때문이고, 높은 차원(역주 : 쓰레드과 그룹)에서 작업하기 위해서는 결과값이 버퍼의 어느 부분에 할당될지 인덱스를 계산해야 하기 때문입니다.
같은 이론이 3차원에도 적용되지만, 복잡해지기 때문에 2차원까지의 예제만 보여드리겠습니다. 3차원에서 버퍼의 위치는 “int id = dispatchID.x + dispatchID.y * groupSizeX + dispatchID.z * groupSizeX * groupSizeY” 와 같이 계산된다는 것만 알고 계시면 됩니다. 그룹 사이즈는 그룹 개수 곱하기 해당 차원에서 쓰레드의 개수입니다.
또한 시멘틱이 어떻게 동작하는지 알아야 합니다. 이 커널 인수의 예제를 보세요.
int3 dispatchID : SV_DispatchThreadID
SV_DispatchThreadID는 이 인수에 대해 GPU가 어떤 값을 전달해야 할지 알려줍니다. 인수의 이름은 상관 없습니다. 원하는 대로 붙이시면 됩니다. 예를 들어 아래 인수는 위와 동일하게 동작합니다.
int3 id : SV_DispatchThreadID
또한 변수 형(type)도 바뀔 수 있습니다. 예를 들어
int dispatchID : SV_DispatchThreadID
Int3가 int로 바뀐 것을 보십시오. 1차원에서 작업할 때는 이것도 상관 없습니다. 마찬가지로 2차원에서는 int2를 사용할 수 있고 원한다면 unsigned int(uint)도 사용 가능합니다.
이제 셰이더에 두 개의 커널이 있으니 dispatch를 호출할 때, GPU에 어떤 커널을 실행하고 싶은지도 알려줘야 합니다. 각 커널은 작성된 순서대로 아이디가 부여됩니다. 우리의 첫 번째 커널은 아이디 0이 되겠고, 다음 것은 1이 됩니다. 셰이더에 커널이 많아지면, 헷갈리기 쉽고, 잘못된 아이디를 설정하기 쉽습니다. 이것은 커널 이름으로 아이디를 찾도록 셰이더에 요청함으로써 해결할 수 있습니다. “int kernel = shader.FindKernel (“CSMain2”);” 줄은 “CSMain2” 커널의 아이디를 얻어옵니다. 이후 아이디를 버퍼를 셋팅하고 dispatch 호출을 할 때 사용할 수 있습니다.
지금은 쓰레드 그룹들에 대한 개념이 약간 헷갈리실 수 있습니다. 그냥 그룹을 하나만 사용하면 안될까요? 뭐 가능합니다만 GPU에서 쓰레드들이 그룹으로 정렬되는 이유가 있다는 것을 아셔야 합니다. 우선 쓰레드 그룹 내 쓰레드 개수에는 제한이 있습니다(셰이더에서 “[numthreads(x,y,z)]”로 정의되어 있는 부분). 이 제한은 현재 1024개 이고, 새로운 하드웨어에서는 다를 수 있습니다. 예를 들어 1차원이라면 최대 “numthreads(1024,1,1)”개, 2차원이라면 “numthreads(32,32,1)”인 식으로 최대치를 가질 수 있습니다. 하지만 쓰레드 그룹의 개수에는 제한이 없고, 백만개 이상의 데이터를 처리해야 할 때에는 쓰레드 그룹의 개념이 필수적입니다. 그룹 내 쓰레드들은 또한 메모리를 공유할 수 있으며, 이는 어떠한 알고리즘에서는 굉장한 성능 이득을 가져다 줍니다. 이에 대해서는 나중 포스트에서 다루도록 하겠습니다.(역주 : 아직 이에 대한 포스팅은 없는 것 같습니다. 이러한 자료 등을 참고하세요)
커널과 쓰레드 그룹에 대해서는 어느정도 다룬 것 같습니다. 하나 더 다루고 싶은 부분이 있는데요. 셰이더에 uniform을 어떻게 전달하는지 입니다. Cg 셰이더와 같은 방식으로 동작하지만, uniform 키워드는 없습니다. 대부분 이 작업은 간단하지만 몇 가지 제가 파악한 부분이 있어 짚어드리고자 합니다.
예를 들어, float을 셰이더에 전달하고 싶을 때는 셰이더에 아래 부분을 추가하고
float myFloat;
스크립트에 아래를 추가하면 됩니다.
shader.SetFloat("myFloat", 1.0f);
벡터를 전달하기 위해서는 아래를 셰이더에 추가하고
float4 myVector;
아래를 스크립트에 추가하면 됩니다.
shader.SetVector("myVector", new Vector4(0,1,2,3));
스크립트에서는 Vector4만 전달 가능하지만, uniform(역주: 셰이더에서의)은 float, float2, float3, float4가 될 수 있습니다. 적절한 값으로 채워지게 됩니다.
이제, 좀 까다로워지는 부분입니다. 배열을 전달할 수 있습니다만, 첫 번째 예제는 작동하지 않는다는 것에 주의하십시오. 이유는 설명해 드리겠습니다. 셰이더에는 아래 코드를 추가하시고,
float myFloats[4];
스크립트에는 아래를 추가하세요.
shader.SetFloats("myFloats", new float[]{0,1,2,3});
이 코드는 동작하지 않습니다. 원래 설계가 이렇게 되었는지, 유니티에서의 버그인지 모르겠습니다. 가능하게 하려면 벡터를 uniform으로 사용해야 합니다. 셰이더에서는
float4 myFloats;
스크립트에서는
shader.SetFloats("myFloats", new float[]{0,1,2,3});
이러면 동작합니다. Float2나 float3도 사용 가능합니다. 단지 float 하나는 안됩니다. 마찬가지로 벡터의 배열은 사용 가능합니다. 셰이더에서는
float4 myFloats[2];
스크립트에서는
shader.SetFloats("myFloats", new float[]{0,1,2,3,4,5,6,7});
여기에선 float4 두개로 이루어진 비열이 있고, 스크립트에서는 8개 float으로 이루어진 배열로 할당됩니다. 행렬에 대해서도 같은 원리가 적용됩니다. 셰이더에서는
float4x4 myFloats;
스크립트에서는
shader.SetFloats("myFloats", new float[]{0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15});
당연히 행렬의 배열도 사용 가능합니다. 셰이더에서는
float4x4 myFloats[2];
스크립트에서는
shader.SetFloats("myFloats", new float[]{0,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});
같은 논리는 float2x2나 float3x3에는 적용되지 않는 것 같습니다. 역시, 버그인지 설계가 그렇게 된 것인지는 모르겠습니다.
오늘의 분량은 여기까지 인 것 같습니다. 다음 글에서는 compute shader에서 텍스처를 사용하는 방법에 대해 다루도록 하겠습니다. 커널 예제에 대한 프로젝트 파일을 다운로드 하실 수 있습니다. 기본적인 것이지만, 필요하다면 받으세요. 이후 튜토리얼들에서도 프로젝트 파일을 추가할 예정입니다.
Project Files
No comments:
Post a Comment