이전 포스팅에서는 CPU의 작업을 GPU로 전환하는 과정을 설명했었다.

 

여기서 이제 좀 더 효율적으로 GPU 에게 작업을 맡겨보자

 

현재 CubeShader.shader 에서 회전 행렬과 이동 행렬을 연산하고 있는데,

GPU를 이용해 이를 병렬로 처리해볼 것이다.

 

어떻게 GPU에게 병렬처리를 맡기죠?

 

바로 컴퓨트 셰이더 를 이용하는 것이다.

 

https://docs.unity3d.com/kr/2018.4/Manual/class-ComputeShader.html

 

컴퓨트 셰이더 - Unity 매뉴얼

컴퓨트 셰이더는 일반 렌더링 파이프라인과 별도로 그래픽 카드에서 실행되는 프로그램입니다. 컴퓨트 셰이더는 대량 병렬 GPGPU 알고리즘 또는 게임 렌더링의 일부를 가속시키기 위해 사용할

docs.unity3d.com

메뉴얼에 설명되어 있지만 좀처럼 개념이 와닿지 않는다.

 

요약하자면, GPU는 대량의 병렬 알고리즘 처리에 특화되어 있는데, 우리는 컴퓨트 셰이더를 이용해 이를 이용할 수 있다는 것

 

쓰기 전에 GPU가 작업을 처리하는 방식과 구조에 대해 간단히 짚고 넘어가보자.

그래야 각각의 코드가 어떤 의미인지 이해할 수 있으니 말이다.

 

먼저 GPU에서는 하나의 작업을 쓰레드라고 명시한다.

위에 말한 병렬 이라는 것도 결국 여러 쓰레드를 병렬로 처리할 수 있다는 뜻이며, 우리는 이 스레드 하나가 어떤 일을 할지 명시해주면 되는 것이다.

 

단 이 스레드는 우리가 어떤 변수를 저장할때 다차원 배열을 선언해서 쓸 수 있듯, (arr[][] 같은 형태) 스레드도 3차원 배열까지 선언할 수 있다.

  • c++ 에서 3차원 배열 선언하는 형태 : float arr[][][]
  • 컴퓨트 셰이더에서 3차원 배열 선언하는 형태 : numthreads(int, int, int)

만약 numthreads(8,8,1) 이라면 8 * 8 * 1 = 64 개의 스레드를 병렬로 돌리겠다 라는 의미이다.

 

c++ 함수 처럼 GPU도 함수라는 개념을 가지고 있는데 이는 커널 이라는 용어를 사용한다.

요 하나의 커널이 여러개의 스레드를 한꺼번에 (병렬로) 돌릴 수 있으며 위에 명시한 numthreads 크기만큼의 스레드들을 가질 수 있다.

여기에 추가로 이 커널 하나를 실행하는 단위를 그룹이라고 명명하기도 하며, 이 그룹까지도 병렬로 처리가 가능하다는 것이다.

 

결국 여러 스레드를 가진 커널 하나가 있고, 커널을 실행하는 그룹들을 동시에 돌릴 수 있는게 바로 다이렉트 컴퓨트 이다.

CPU가 할일을 GPU에게 맡기고, GPU는 이를 다이렉트 컴퓨트를 통해 연산하는 이러한 기술을 GPGPU 라고 명명한다

 

CUDA 의 Direct Compute 모델. Block 은 그룹으로 간주할 수 있다

개념도 알았으니, 실제 스크립팅을 통해 구현해보자 

 

먼저 Unity 에서 다음 메뉴를 실행해 Compute Shader 파일을 생성하자.

하고 나면 .compute 확장자를 가진 파일이 생성될 것이다.

Assets / Create / Shader / Compute Shader

아래 코드를 파일에 붙여넣기 한 후 한줄 한줄 살펴보도록 하자

#pragma kernel MyKernel

uint _Resolution;

float _Time;

RWStructuredBuffer<float4x4> _Positions;

float4x4 RotateYMatrix(float r)
{
    float sina, cosa;
    sincos(r, sina, cosa);
                
    float4x4 m;

    m[0] = float4(cosa, 0, -sina, 0);
    m[1] = float4(0, 1, 0, 0);
    m[2] = float4(sina, 0, cosa, 0);
    m[3] = float4(0, 0, 0, 1);

    return m;
}

float4x4 PositionMatrix(float3 pos)
{
    float4x4 m;

    m[0] = float4(1,0,0,pos.x);
    m[1] = float4(0,1,0,pos.y);
    m[2] = float4(0,0,1,pos.z);
    m[3] = float4(0,0,0,1);

    return m;
}

[numthreads(8,8,1)]
void MyKernel(uint3 id : SV_DispatchThreadID)
{
    float x = (-(_Resolution * 0.5f) + id.x) * 2;
    float z = (-(_Resolution * 0.5f) + id.y) * 2;

    float4x4 tfM = mul(PositionMatrix(float3(x,0,z)), RotateYMatrix(_Time));
    
    if (id.x <_Resolution && id.y <_Resolution) {
        _Positions [id.x * _Resolution + id.y] = tfM;
    }
}

 

 

더보기

#pragma kernel 커널이름

컴퓨트 쉐이더는 최소 하나의 커널(함수)를 선언하고 정의해야 한다

쓰레드 하나가 실행할 작업을 이 함수 안에 구현해야 하는 것이다.

여기서는 MyKernel 이라는 이름으로 커널을 선언

 

더보기

uint _Resolution, float _Time

쉐이더에 변수를 전달하는 것과 같은 방식으로, 스크립트에서 해당 변수에 값을 설정할 수 있다.

 

더보기

RWStructuredBuffer<float4x4> _Positions;

우리는 지금 컴퓨트 쉐이더를 통해 큐브의 위치와 회전값을 처리해서 넘겨줘야 한다.

이를 위해 나는 4x4 행렬을 통째로 CubeShader.shader 에 전달할 것이고, vertex 셰이더에서 해당 행렬을 통해 정점 위치를 변환할 예정이다.

 

StructuredBuffer는 스크립트에서 썼던 ComputeBuffer를 전달받기 위한 자료형인데, 앞에 RW가 붙어있다.

이는 ComputeBuffer를 읽기만 하는게 아니라 쓰기도 하겠다는 의미로서, 밑에서 설명하겠지만, 커널 함수 내부에서 위치를 계산한 후 이 RWStructuredBuffer 에 값을 셋팅할 것이다.

 

만약 앞에 RW를 붙이지 않는다면, 읽기만 하겠다는 의미

 

더보기

RotateYMatrix, PositionMatrix 함수

CubeShader.shader 에서 썼던 함수를 그대로 들고 왔다.

다시 한번 짚고 넘어가지만, 우리는 렌더 쉐이더 (CubeShader.shader) 가 하던 일을 Compute Shader를 통해 병렬로 처리하기 위해 이런 일련의 작업을 하고 있다.

그렇기 때문에 함수를 그대로 가져온 것

 

더보기

[numthreads(8, 8, 1)]

중요한 한줄이다.

위에서 설명한 대로 커널 하나가 처리할 스레드의 갯수를 3차원으로 설정하는 곳으로 꼭 커널함수의 윗줄에 선언되어야 한다.

각각의 숫자를 어느정도까지 쓸 수 있는지는 GPU의 종류에 따라 다르다고 한다.

또한 스레드의 최소 갯수 또한 효율에 영향이 있다고 하는데, 여기서는 x, y 각각 8 총 64개의 스레드를 설정하겠다.

 

더보기

void MyKernel(uint3 id : SV_DispatchThreadID)

실질적인 쓰레드가 처리할 일을 선언하는 곳

매개변수로서 SV_DispatchThreadID 를 받고 있는데, 쓰레드 하나가 실행될때마다 고유한 아이디가 발급되고, 저 매개변수를 통해 우리는 쓰레드별 고유한 인덱스를 받아올 수 있는 것이다.

 

만약 내가 64개의 스레드를 실행하라고 명령하면, id 에 0~63까지의 값이 전달된다는 것

스레드별 고유한 아이디를 이용해 각 큐브의 위치를 미리 계산할수 있다.

나는 여기서 이동행렬, 회전행렬을 미리 다 계산해서 렌더 셰이더에 넘겨줄 것이므로 행렬을 생성해서 RWStructuredBuffer 에 셋팅하고 있는 걸 확인할 수 있다.

여기까지 Compute Shader 의 코드설명이 끝났다.

 

요약하자면, 쓰레드가 처리할 일을 커널 함수에 구현하되, 이를 위해 외부로부터 변수를 전달받아야 하기 때문에 몇가지의 변수와 버퍼를 설정했다는 것

 

남은건 이제 이 컴퓨트 셰이더에 변수를 전달하고, 실행하라는 명령을 내리는 것뿐

이를 위해 InstancedProcedural.cs 파일을 몇군데 수정하자.

 

public class InstancedProcedural : MonoBehaviour
{
    [SerializeField] private ComputeShader computeShader;
    [SerializeField] private Material material;

    [SerializeField] private Mesh mesh;

    [SerializeField] private int count;
    
    private ComputeBuffer positionBuffer;
    
    private static readonly int PositionID = Shader.PropertyToID("_Positions");
    private static readonly int ResolutionID = Shader.PropertyToID("_Resolution");
    private static readonly int TimeID = Shader.PropertyToID("_Time");
    

    private void Update()
    {
        positionBuffer?.Release();
        positionBuffer = new ComputeBuffer(count * count, 4 * 16);
        
        computeShader.SetInt(ResolutionID, count);
        computeShader.SetFloat(TimeID, Time.time);
        computeShader.SetBuffer(0, PositionID, positionBuffer);
        
        int groups = Mathf.CeilToInt(count / 8f);
        computeShader.Dispatch(0, groups, groups, 1);
        
        material.SetBuffer(PositionID, positionBuffer);
        Graphics.DrawMeshInstancedProcedural(
            mesh, 0, material, new Bounds(Vector3.zero, Vector3.one), count * count
        );
    }
    
    private void OnDisable()
    {
        positionBuffer?.Release();
        positionBuffer = null;
    }
}

일단 코드량이 더 줄었다. 왜냐하면 위치계산을 하던 부분이 컴퓨트 셰이더로 빠졌기 때문

 

변경된 부분만 살펴보자면,

[SerializeField] private ComputeShader computeShader;

//시리얼라이즈를 통해 위에서 만든 Compute Shader 를 연결해주고
positionBuffer = new ComputeBuffer(count * count, 4 * 16);

//기존에는 x,y,z 의 float 값 3개를 전달하기 위해 4 * 3 = 12 를 썼다면,
//지금은 4x4 행렬을 전달하기 위해 4 * 16 크기로 바꾸었다.
computeShader.SetInt(ResolutionID, count);
computeShader.SetFloat(TimeID, Time.time);
computeShader.SetBuffer(0, PositionID, positionBuffer);

//컴퓨트 셰이더에서 위치와 회전 계산을 위해 변수를 전달하고 있으며,
//계산한 결과를 받기 위해 ComputeBuffer 도 전달한다.
int groups = Mathf.CeilToInt(count / 8f);
computeShader.Dispatch(0, groups, groups, 1);



중요한 부분이다

컴퓨트 셰이더를 실행시키는 함수는 Dispatch인데
첫번째 인자에는 커널의 인덱스를 넣는다
미리 언질하지 않았지만, 하나의 컴퓨트 셰이더는 여러개의 커널을 가질 수 있다.
우리는 현재 커널을 하나만 선언했으므로 0을 넣는다.

이하 매개변수에는 그룹의 갯수를 설정해야 하는데,
아까 위에서 커널을 실행하는 단위는 그룹이라고 했었다.
이 그룹 또한 3차원으로 설정 가능한데, 저 세 매개변수를 모두 곱한게 총 그룹의 갯수가 된다.

자 현재 우리는 컴퓨트 셰이더에 총 64개의 스레드가 병렬 처리되도록 설정했었다 [numthreads(8,8,1)]
하지만 큐브를 count * count 만큼 그려야 하기 때문에 count * count 만큼의 스레드가 필요하단 얘기인데,
count / 8 을 하게 되면 1차원의 그룹 갯수가 도출되고, 이를 x, y 그룹 갯수에 설정하면, 총 돌려야 할 그룹 갯수를 산출할 수 있다.

예를 들어 설명해보자

만약 count 가 128 이라면, 큐브를 총 128 * 128 그려야 하므로 16,384 개의 스레드가 필요하다.
여기서 하나의 커널이 현재 8 * 8 * 1 = 64개의 스레드를 병렬처리 할 수 있으므로
16,384 / 64 하면 필요한 그룹의 갯수가 256개가 나온다.

여기서 Dispatch 함수에 256, 1, 1 로 전달할 수 있지만, 3차원으로도 그룹을 전달 할 수 있기 때문에
x와 y에다가 균일하게 분배하면 16, 16, 1 이렇게도 전달이 가능하다.
결국 요 16 이라는 숫자는 128 / 8 과 같으므로

int groups = count / 8 과 같다

추가적으로 count가 8의 배수가 아닐 경우도 있으므로 올림처리를 해서 그룹이 부족하지 않도록 넉넉히 잡아준 것
material.SetBuffer(PositionID, positionBuffer);

//이제 CubeShader.shader 에는 float3 형태가 아닌 float4x4 형태의 버퍼가 전달된다.

여기까지가 코드 변경부분이다.

 

렌더 셰이더 (CubeShader.shader) 에서는 전달받은 행렬을 통해 정점을 변환 시켜주면 되기 때문에 코드량도 확 줄게 된다.

CubeShader.shader 를 살펴보자

StructuredBuffer<float4x4> _Positions;

...

v2f vert (Input v, uint instanceID : SV_InstanceID)
{
    float4 worldPos = mul(_Positions[instanceID], v.positionOS);
    v2f o;
    o.pos = mul(unity_MatrixVP, worldPos);
    o.normal = mul(v.normalOS, (float3x3)Inverse(_Positions[instanceID]));
    return o;
}

StructuredBuffer는 이제 float4x4 를 받아야 하므로 자료형을 바꿔주었고,

 

버텍스 셰이더 내부에서 정점 위치에 월드 행렬을 곱한 게 전부

추가적으로 행렬을 전달받았기 때문에 노멀 연산도 문제 없다 :)

 

이제 다시 유니티를 실행해보면!! 더욱더 부드러워진 당신의 큐브세상을 볼 수 있을 것이다.

(큐브가 귀여워 보인다면 이미 당신은 프ㅊ...)

 

이로서 컴퓨트 셰이더를 어떤 상황에 어떻게 쓰는지 살펴보았다.

예제 코드는 https://github.com/SeonHwan/ComputeShaderSample/tree/9558675375a04b8e82e5b1498849db03fc51fd6c 를 통해 확인할 수 있다.

 

프로젝트의 퍼포먼스가 떨어져 병렬처리를 고려하고 계신 분에게 조금이나마 도움이 되었으면 하는 바램이다.

다음 포스팅에서는 Job System 을 통해 CPU 작업을 Worker Thread 에게 어떻게 분산시킬 수 있는지를 다뤄보겠다.

'Unity > 최적화' 카테고리의 다른 글

Batch 개념 및 종류  (0) 2021.02.16
병렬처리 기법 1  (0) 2021.02.04

+ Recent posts