본문 바로가기
그래픽스

DirectX11,Opengl 에서 Compute Shader 사용하기.

by greenherb 2021. 9. 2.

이전 포스팅에서 Assimp를 이용하여 Skeletal Animation을 구현했습니다.

애니메이션을 구현하기위해 하드웨어 스키닝을 정점쉐이더에서 진행했는데요. 이러면 한가지 문제점이 생기게됩니다. 

기존의 애니메이션을 사용하지않는 정적인 메쉬에대한 쉐이더코드가 다르고 애니메이션 메쉬를 위한 쉐이더코드가 따로 있어야합니다. 예를들어 그림자매핑을 위해 깊이값을 기록하는 쉐이더 코드가 있다고하면 정적메쉬를 따로모아서 쉐이더코드를 바인딩한다음 그려야하고 다그렸으면 다시 애니메이션 메쉬들을 모아서 애니메이션을 위한 쉐이더를 바인딩하여 그려야합니다.

이럴경우 애니메이션에 필요한 계산된 Bone 행렬이 정점쉐이더에 전달된후 정점쉐이더에서 계산을할 것입니다. 그다음 그림자맵을 만들었으면 이제 쉐이딩 단계에서 또다시 그려야합니다. 이때 애니메이션 메쉬를 그리기위해 다시 정점쉐이더에서 Bone 행렬을 이용하여 가중치에따라 다시 정점의 위치를 계산하겠죠.

이러면 이 과정에서만 총 2번의 Bone행렬 에따른 정점위치 계산이 이루어집니다. Bone의 갯수가 많고 정점의 갯수가 많으면 상당히 별로겠죠.

그래서 어떻게하면 쉐이더 코드를 통일하고 렌더링할 수 있을지 고민했습니다. 언리얼 엔진 코드도 보고 오픈소스 엔진 코드도 확인하면서 Bone행렬에 따른 정점위치를 계산하는 정점쉐이더의 결과를 정점버퍼에 저장하는 방법 (StreamOutput 과 Transform FeedBack) 그리고 컴퓨트 쉐이더를 사용하여 Bone행렬에따른 정점의 위치를 계산하고 정점버퍼에 저장하는 방법이 있습니다.

저는 두 방법중 Compute Shader를 사용하여 애니메이션 정점을 계산하고 정점버퍼에 저장한뒤 애니메이션,정적 메쉬구분없이 렌더링을 진행해보고자 합니다. 그러기 위해서는 Compute Shader를 사용하는법을 알아야하는데요

DirectX 와 OpenGL에서의 Compute Shader의 차이점및 사용하기위한 준비단계를 알아보도록 하겠습니다.

Compute Shader는 양쪽 API 둘다 렌더링 파이프라인에 포함된 기능이아닙니다. 하지만 GPU 자원을 직접 읽거나 기록할 수 있습니다. Compute Shader은 아무것도 그리지 않지만 GPU에 직접 접근하여 병렬계산을 진행 할 수 있습니다. GPU 자원을 읽고 씀으로 Compute Shader의 출력을 렌더링 파이프라인에 묶는것도 가능합니다.

OpenGL에서는 컴퓨트 셰이더는 다른 셰이더와 섞일수 없습니다. 즉 프로그램에 정점,단편 셰이더가 붙어있을 경우 링크에 실패하게 됩니다.

위키 번역글입니다! 오역이 있을수 있으므로 양해 부탁드립니다.

https://www.khronos.org/opengl/wiki/Compute_Shader

 

Compute Shader - OpenGL Wiki

Compute Shader Core in version 4.6 Core since version 4.3 Core ARB extension ARB_compute_shader A Compute Shader is a Shader Stage that is used entirely for computing arbitrary information. While it can do rendering, it is generally used for tasks not dire

www.khronos.org

컴퓨트 셰이더에서는 다수의 스레드들이 스레드 그룹을 이룹니다. (OpenGL 에서는 work Group 이라고합니다) 이 그룹이 유저가 실행할 수 있는 가장 작은 단위입니다. 즉 다수의 그룹이 유저에의해 지정된 연산을 컴퓨터 셰이더가 호출 됬 을때 수행하게됩니다.  이 그룹들은 모여서 3차원을 이루는데요 각각 X그룹, Y그룹, Z그룹 으로 이루어지게됩니다. 이 그룹들의 개수를 통해 1차원 혹은 2,3차원의 계산을 수행할 수 있습니다. 이렇게 3차원까지 있으면 이미지 정보 계산이나 파티클의 정보를 계산하는데 도움이 되겠죠

스레드그룹이 연산을 실제로 한다면 순서없이 계산을 진행합니다. 예를들어 주어진 그룹이 (3,1,2)개일경우 (0,0,0) 그룹부터 먼저 실행될수있고 다음인 (0,0,1)이아닌 (2,0,0)그룹이 계산을 진행할 수 도 있습니다. 즉 컴퓨트 셰이더는 각각 그룹의 순서에 연연하지않고 연산을 진행합니다.

단일 그룹을 실행한다고해서 단 한번만 컴퓨트 셰이더가 호출된다고 생각하시면 안됩니다. 그룹이라고 불리는 이유가 있듯이 하나의 그룹에는 많은 컴퓨트 셰이더의 호출이 있을수 있습니다. 즉 컴퓨트 셰이더 그자체로 얼마나 정의됬는지이지 얼마나 컴퓨트 쉐이더를 호출했는지가 아닙니다. 그룹에는 로컬 사이즈가 있습니다.

모든 컴퓨트 셰이더는 3차원의 로컬크기를 가지는데요 이것은 각 워크 그룹마다 얼마나 셰이더를 호출할지 정의합니다. 그러므로 만약 컴퓨트 셰이더의 로컬크기가 (128,1,1) 이고 워크그룹의 갯수를 (16,8.64) 로 지정했다면 1,048,576 번의 셰이더 호출이 이루어지게 됩니다. 

이러한 구분은 다양한 형식의 이미지 압축 혹은 압축을 푸는데 유용합니다. 로컬 사이즈는 이미지 정보의 블록이 되겠죠 , 그룹 갯수가 블록 사이즈로 나눠진 이미지 크기가 될것입니다.

이러한 그룹안에있는 각각의 호출은 평행하게 실행됩니다. 워크 그룹갯수와 로컬크기를 구분한 주된 목적은 그룹안에서 이루어지는 다른 컴퓨트 셰이더의 호출이 특별한 함수 그리고 공유변수를 통해 소통할 수 있기 때문이죠. 다른 그룹에서의 호출은 (같은 컴퓨트 셰이더 Dispacth에서)시스템에서 내부적으로 데드락킹 없이는 효과적으로 소통할 수 없습니다. 

영어를 해석했는데 상당히.. 이해하기 좀어렵죠.. 죄송합니다 영어실력이..

결국 컴퓨트 셰이더에는 스레드 그룹을 지정할 수 있으며 각 스레드 그룹마다 다수의 스레드들이있습니다. 그리고 스레드 그룹마다 소통을 위한 공유메모리가 존재하구요. 한 스레드그룹의 공유메모리는 해당 그룹내의 스레드들이 모두 접근할 수 있으며 다른 스레드그룹에서는 접근할 수 없습니다.

위에서 시스템에서 내부적으로 데드락킹 없이는 소통할 수 없다는 뜻은 다른 스레드 그룹간의 동기화가 불가능하기 때문입니다. 왜냐면 다른 그룹들의 수행순서는 응용프로그램에서 전혀 제어할 수 없기 때문이죠. 위에서 정의하는 로컬사이즈는 그룹안의 스레드갯수를 의미합니다.

간단하게 glsl 코드부터 보겠습니다.

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
#type compute
#version 450 core
struct Data
{
    vec3 a;
    vec2 b;
};
layout(std430,binding =0) readonly buffer Data1
{
    Data input1[];
};
 
layout(std430,binding =1) readonly buffer Data2
{
    Data input2[];
};
 
layout(std430,binding =2) writeonly buffer Data3
{
    Data outputData[];
};
 
layout (local_size_x = 128, local_size_y = 1, local_size_z = 1) in;
 
void main()
{
  uint index = gl_GlobalInvocationID.x;
 
  outputData[index].a = input1[index].a + input2[index].a;
  outputData[index].b = input1[index].b + input2[index].b;
}
cs

간단한 컴퓨트 셰이더 코드입니다. 버퍼부분은 넘어가고 local_size_x 부분을 보시길 바랍니다. 128로 되어있으므로 해당 컴퓨트 셰이더의 단일 그룹의 스레드 갯수는 128개로 지정된것 입니다.

위 컴퓨트 셰이더 코드를 실행하기위해서는 OpenGL에서는 프로그램이 정점,단편 셰이더를 포함하고 있으면 안됩니다. 컴퓨트 셰이더만 가지고있는 프로그램은 바인딩후 

void glDispatchCompute(GLuint num_groups_x​, GLuint num_groups_y​, GLuint num_groups_z​);

함수를 호출하여 컴퓨트 셰이더를 실행합니다. 각 파라매터가 그룹의 갯수를 의미합니다. 그룹의 크기는각 장치마다 제한이 존재하니 염두해 두셔야합니다. GL 에서는 GL_MAX_COMPUTE_WORK_GROUP_COUNT로 glGetIntegeri_v 함수를 이용하여 쿼리 할 수 있습니다. 만약 이 숫자를 넘어서는 값으로 그룹갯수를 지정하면 에러가 발생하게됩니다. 

컴퓨트 셰이더는 사용자정의 출력은 불가하고 데이터를 입력하려면 storage Buffer 혹은 Texture 을 사용해야합니다.그리고 이런 입력들을 인덱싱할때 컴퓨트셰이더에서 사용할 수 있는 내부변수가 있는데요

in uvec3 gl_NumWorkGroups; - dispatch 함수를 통해 전달된 워크 그룹의 갯수를 의미한다.

in uvec3 gl_WorkGroupID; - 현재 워크그룹의 ID이다.

in uvec3 gl_LocalInvocationID; -  워크 그룹 내에서의 쓰레드 ID

in uvec3 gl_GlobalInvocationID; - 모든 dispatchcall에서 구분가능한 글로벌 ID 값이다.

in uint gl_LocalInvocationIndex; - 1차원 버전의 gl_LocalInvocationID 이다.

 DirectX 의 시스템 변수를 설명할게요

SV_GroupID : 스레드 그룹 ID

SV_GroupThreadID : 그룹안의 스레드의 그룹안에서의 고유한 ID 값을 의미합니다.

SV_DispatchThreadID : 이 값은 gl_GlobalInvocationID 값과 같은 의미입니다. 즉 모든 그룹에서의 단일 스레드의 고유한 ID 값을 의미합니다.

SV_GroupIndex : 그룹 스레드ID 값의 선형 (1차원버전) 즉 gl_LocalInvocationIndex와 같은 의미입니다.

즉 이렇게 다양한 ID값을 통해서 입력받은 자료의 인덱싱하는데 사용합니다.

이렇게 입력값이 있다면 출력값은 어떨까요? 컴퓨트 셰이더는 출력변수를 가지지 않습니다. 컴퓨트 셰이더가 출력값을 생산하게 만들고 싶으면 컴퓨트 셰이더의 연산결과를 저장한 버퍼나 이미지를 전달해줘야합니다.(왜냐면 파이프라인과 별개인 셰이더이기 때문에 다른 셰이더로 전달할 출력이 없기 때문이에요!)

-공유 자원-

컴퓨트 셰이더에는 글로벌 변수들이 있는데요. OpenGL에서는 Shared 지시자를 통해 선언 될 수 있습니다. 그러한 변수들은 하나의 그룹안의 모든 계산호출사이에서 공유될 수 있습니다. 불분명한 타입은 공유자원으로 선언할 수 없지만. 배열이나 구조체의경우는 괜찮습니다.

Dx 에서는 groupshared 지시자로 공유메모리를 지정합니다. 이렇게 할당된 변수는 32KB를 넘으면 안됩니다. 이 공간은 스레드 그룹에 고유한 공간이므로 그룹내 스레드ID를 사용하면 각각의 스레드들을 하나씩 대응 시킬 수있습니다!

워크 그룹의 시작에서 이러한 공유메모리들은 초기화가 되어있지 않으므로 이러한 변수들은 초기화를 진행시켜주는 구문이 불법입니다.

shared uint foo = 0; // No initializers for shared variables.

groupshared float4 gCache[256]; (DX에서의 선언)

자이제 다시 hlsl 코드를 보여주면서 위에서 알았던내용을 다시 설명해볼게요

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Data
{
    float3 v1;
    float2 v2;
};
StructuredBuffer<Data> gInputA : register(t0);
StructuredBuffer<Data> gInputB : register(t1);
RWStructuredBuffer<Data> gOutput : register(u0);
 
[numthreads(128,1,1)]
void CSMain(int3  dtid : SV_DispatchThreadID)
{
    gOutput[dtid.x].v1 = gInputA[dtid.x].v1 + gInputB[dtid.x].v1;
    gOutput[dtid.x].v2 = gInputA[dtid.x].v2 + gInputB[dtid.x].v2;
}
cs

[numthreads(x,y,z)] 이부분이 DX에서는 한스레드그룹의 스레드 갯수를 정의하는 부분입니다.

자 여기서 glsl 나 dx나 처음보는 자료형이 나오게 됩니다. 

StructuredBuffer,RWStructuredBuffer 이게 도대체 무엇일까요?

StructuredBuffer은  구조적 버퍼라고하며 배열이라고 생각하시면됩니다. 즉 Struct Data 의 자료를 배열형으로 가지고있는 버퍼라고 생각하시면되요. DX 에서는 일반 버퍼와 생성하는방법은 똑같으나 구조적 버퍼임을 알려주는 플래그(D3D11_RESOURCE_MISC_BUFFER_STRUCTURED)와 단일 원소크기를 지정해줘야합니다.

해당 구조적버퍼는 GPU 상에서 오직 읽기만 가능합니다. 컴퓨트 셰이더에 묶기위해서는 ShaderResourceView를 만들면됩니다.(물론 쉐이더자원뷰로 사용하려면 바인딩 플래그에 D3D11_BIND_SHADER_RESOURCE 를 지정해야겠죠?)

자 그렇다면 컴퓨트 셰이더에서 연산된 결과를 저장하는곳은 어딜까요? RWStructuredBuffer입니다. 컴퓨트 셰이더는 따로 출력이 없으므로 해당 버퍼에 기록을 해야합니다. 해당 버퍼는 읽기와 쓰기가 둘다가능합니다 하지만 구조적버퍼를 생성할때 들어가는 바인딩플래그가 쉐이더자원이아닌 D3D11_BIND_UNORDERED_ACESS 플래그가 들어갑니다. 그리고 이러한 바인딩플래그를 가진 버퍼들은 쉐이더자원뷰가아닌 순서없는뷰 (Unordered Access View)를 생성해야합니다

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
desc.Usage = D3D11_USAGE_DEFAULT;
desc.ByteWidth = size * count;
desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
desc.CPUAccessFlags = 0;
desc.StructureByteStride = size;
desc.MiscFlags = D3D11_RESOURCE_MISC_BUFFER_STRUCTURED;
 
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
srvDesc.Format = DXGI_FORMAT_UNKNOWN; //For StructureBuffer
srvDesc.ViewDimension = D3D11_SRV_DIMENSION_BUFFEREX;
srvDesc.BufferEx.FirstElement = 0;
srvDesc.BufferEx.Flags=0;
srvDesc.BufferEx.NumElements = count;
 
desc.Usage = D3D11_USAGE_DEFAULT;
desc.ByteWidth = size * count;
desc.BindFlags = D3D11_BIND_UNORDERED_ACCESS;
desc.CPUAccessFlags = 0;
desc.StructureByteStride = size;
desc.MiscFlags = D3D11_RESOURCE_MISC_BUFFER_STRUCTURED;
 
D3D11_UNORDERED_ACCESS_VIEW_DESC uavDesc;
uavDesc.Format = DXGI_FORMAT_UNKNOWN;
uavDesc.ViewDimension = D3D11_UAV_DIMENSION_BUFFER;
uavDesc.Buffer.FirstElement = 0;
uavDesc.Buffer.Flags = 0;
uavDesc.Buffer.NumElements = count;
cs
위 코드는 각각 구조적버퍼를 읽기전용과 읽기쓰기전용으로 만드는 것을 보여줍니다. 처음 구조적 버퍼에대한 자원뷰를 만들때 포맷형식이 DXGI_FORMAT_UNKNOWN 으로 표시된것이 보일것입니다. 이것은 일반적으로 우리가아는 텍스쳐 자원뷰는 텍스쳐의 형식이 정해져있지만 우리가 사용하고자하는 정보는 사용자정의 데이터이기때문에 알수없음으로 지정해야합니다.

두번째로 순서없는뷰 를위한 구조적버퍼를 만들때 바인딩플래그와함께 각각 뷰의 ViewDimension의 차이도 살펴보시길바랍니다.

그렇다면 DirectX에서는 읽기전용버퍼와 읽기쓰기전용버퍼를 컴퓨트 셰이더에 전달하여 작업을한다면 OpenGL에서는 어떻게 처리할까요? OpenGL에는 Shader Storage Buffer Object 라는것이있습니다.

-Shader Storage Buffer Object-

SSBO는 Uniform Buffer Objhect와 비슷합니다. glsl 의 인터페이스 블록으로 정의되고 사용방법도 유니폼 버퍼와 같습니다. 그리고 SSBO또한 바인딩 포인트를 가지고있고요 하지만 주된 차이점은 

1. SSBO 는 훨씬큽니다. OpenGL 스펙에서는 UBO가 16KB 까지 크기를 보장합니다. (구현에따라 더 크게 할수도 있습니다) SSBO의 크기는 128MB 까지 보장합니다. 대부분의 구현에서는 GPU 메모리의 한계까지 할당하도록 해줄겁니다.

2.SSBO는 쓰기가능하며 심지어 원자적으로 기록합니다. SSBO의 읽기/쓰기 들은 Incoherent memory accesses 를 사용하는데. 그렇기 떄문에 Image Load Store연산처럼  적절한 배리어들이 필요합니다 (배리어는 나중에 설명하도록 하겠습니다) 

3.SSBO는 변수 공간을 가질 수 있으며 버퍼의 범위는 바인딩된 특정버퍼에 따라 다릅니다. (UBO는 고정된 공간을 꼭 가져야하지만)  이것은 임의 길이의 배열을 SSBO에서 가질 수 있다는것을 의미합니다 실제 배열의 크기는 바인딩된 버퍼의 크기를 따라가며 이것은 런타임때 쉐이더안에서 length 함수를 사용하여 질의 될 수 있습니다.

4.SSBO 접근들은 모두가 동일하게 UBO의 접근보다 느릴것입니다. SSBO는 일반적으로 buffer Texture 같이 접근합니다. UBO 데이터가 내부 쉐이더 접근 가능한 메모리 읽기이기 때문에 UBO는 SSBO보다 빠릅니다.

이러한 특징을 가지는게 SSBO입니다.  즉 Opengl 에는 StructuredBuffer라는 DX처럼 RWStructuredBuffer와 구분하지 않습니다. StructuredBuffer의 특징중하나가 임의크기의 배열을 받을 수있다는것처럼(우리가 코드상에서는 크기를 정해줬지만 hlsl 코드상에서는 그 크기를 코드에서 표면상으로 보여지는것이 없어요!) SSBO의 특징중 하나가 StructuredBuffer입니다. 동시에 RWStructuredBuffer처럼 읽기 쓰기가 가능하다는것이죠!

그래서 구현할때 StructuredBuffer과 RWStructuredBuffer 사이의 성능 차이가 있는지 궁금했습니다. 아직 확인은 못했지만 OpenGL의경우 SSBO하나로 처리하고 있으니 말이고 일단 따로따로 생성하여 바인딩하고는 있지만 성능차이가 없을경우 RWStructuredBuffer 하나로 합치려고합니다.

SSBO를 쉐이더 안에서 선언할때 buffer 키워드가 들어갑니다 위에 glsl 코드에서 buffer 키워드를 확인 할 수 있을겁니다. 여기서 buffer 앞에 메모리 지시자를 확인해야하는데 이 지시자를 통해 해당 변수에 어떤 메모리 연산을 행할지 정해줍니다.

coherent - 모든 쉐이더 호출에서 해당 메모리는 일관성을 유지하는 지시자 인것같습니다.. 위키글을 확인해봐도 정확하게 해석이 안되네요. 일단 직역을 하자면 컴파일러는 해당 지시자를 가진 변수의 값을 통해 읽고 변경하는것이 오직 하나의 쉐이더 호출이라고 가정한다. 다른 쉐이더의 호출은 해당 변수를 통해 변경된 값을 볼수 없다. 

이 지시자를 사용하려면 독립적인 쉐이더 호출이 다른 쉐이더들과 소통이 가능하도록 허용해야한다. 그러므로써 메모리 접근의 일관성을 강화하기 때문이다.

이지시자를 사용하려면 적절한 메모리 배리어가 수행되야한다 그래야 가시성 을 얻을 수 있다.  다른 렌더링 커맨드에서 쉐이더호출 간의 소통은 이 지시자대신에 glMemoryBarrier를 사용해야한다.

volatile - 컴파일러는 일반적으로 다른 동기화 혹은 메모리 배리어 이후에만 변수를통해 접근되는 값이 변경될것이라고 가정합니다. 이 지시자는 컴파일러가 저장공간의 내용이 언제든지 변할수 있다고 가정합니다.

retrict - 일반적으로 컴파일러는 같은 쉐이더안에서 별도의 변수들을 통해 같은 이미지/버퍼에 접근할 수 있다고 가정합니다. 그러므로 하나의 변수에 쓰기고 두번째로 읽기를 수행한다면 컴파일러는 방금 기록한 내용을 읽는것이 가능하다고 가정합니다. 이 지시자는 컴파일러에게 특정한 변수는가 쉐이더호출 안의 메모리에 보여지는 변수를 수정할수있는 유일한 변수임을 알려줍니다. 그리고 컴파일러에게 읽기/쓰기를 더 최적화하는것을 허용합니다.

readonly -  보통의경우 컴파일러는 읽고 쓰기를 원한다면 하도록 허용합니다. 하지만 이 지시자를 사용하면 해당 변수는 오직 읽기 연산만을 위해 사용됩니다(원자연산 이 쓰기처럼 계산 되기때문에 금지됩니다)

wirteonly - 이 지시자를 사용하면 변수는 오직 쓰기연산을위해 사용됩니다( 원자 연산이 읽기처럼 계산되기때문에 금지됩니다)

wirteonly,readonly는 상호배제적이지 않습니다. 이 지시자를 가진 변수들은 여전히 자원정보에 대한 질의를 수행할 수 있습니다 예를들어 위지시자를 가진 image 변수들은 image의 사이즈를 확인할 수 있습니다.

참 복잡하죠.. 저도 정확하게는 모릅니다. 하지만 SSBO 에서 읽기쓰기던 제한하는 지시자가 있다는것만 알겠군요.. 정확히 어디에 쓰일지는 좀 더 봐야 알것같습니다.

사실 glsl 코드에서도 readonly,writeonly 키워드가 딱히 필요 없이 buffer만으로도 가능합니다만 신기하게도 위 키워드를 지정했을경우 실행속도가 좀더 빠릅니다!

자 그렇다면 SSBO 오브젝트는 어떻게 만드는것일까요? 정말 간단합니다. 기본적으로 OpenGL은 버퍼는 데이터만 담고있는 경우이고 해당 데이터를 타겟으로 어떻게 해석하냐에 따라 사용방법이 다릅니다.

1
2
3
4
5
6
this->dataSize = size;
this->count = count;
 
glGenBuffers(1&m_renderID);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_renderID);
glBufferData(GL_SHADER_STORAGE_BUFFER, int(size * count), pData, GL_STATIC_DRAW);
cs

버퍼를 생성한후에 GL_SHADER_STORAGE_BUFFER로 바인딩후 glBufferData를통해 들어온 데이터를 입력해주시면됩니다. 실제로 만약 버퍼내용을 정점버퍼로 채웠을경우 처음에는 GL_ARRAY_BUFFER  를 사용하여 버퍼를 바인딩하고 데이터를 넣겠죠? 하지만 해당 버퍼를 다시 GL_SHADER_STORAGE_BUFFER 타겟을통해 바인딩할경우 SSBO로 컴퓨트 셰이더에 바인딩이 가능합니다.

DirectX의 경우 버퍼를 만드는 시점부터 바인드 플래그를통해 나누는것이랑은 좀 다르겠죠 물론! DX에서도 버퍼만들때 다 때려넣으면 되긴하겠다만.. 플래그의 갯수와 성능의 연관성은 저도 잘 모르겠습니다.! 어찌보면 GL이 좀더 사용하기가 편하죠

glBindBufferBase(GL_SHADER_STORAGE_BUFFER, slot, m_renderID); 

위 함수를 호출하여 SSBO를 바인딩합니다. 

자이제 그러면 간단한 벡터의 합을 계산하는 코드를 짜보겠습니다 정말 간단해요

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
std::vector<Data> dataA(32);
std::vector<Data> dataB(32);
 
for (int i = 0; i < 32++i)
{
    dataA[i].v1 = glm::vec3(i, i, i);        
    dataA[i].v2 = glm::vec2(i, 0);
    dataB[i].v1 = glm::vec3(-i, i, 0.0f);
    dataB[i].v2 = glm::vec2(0-i);
}
 
InputBufferA = ShaderBuffer::Create(sizeof(Data), 32, BufferType::Read, dataA.data());
InputBufferB = ShaderBuffer::Create(sizeof(Data), 32, BufferType::Read, dataB.data());
OutputBufferA =ShaderBuffer::Create(sizeof(Data), 32, BufferType::ReadWrite);
 
computeShader->Bind();
InputBufferA->Bind(0, ShaderType::CS);
InputBufferB->Bind(1, ShaderType::CS);
OutputBufferA->Bind(0,ShaderType::CS);
 
// Check The Compute Shader Calculation time
std::chrono::system_clock::time_point time1 = std::chrono::system_clock::now();
RenderCommand::DispatchCompute(111);
std::chrono::system_clock::time_point time2 = std::chrono::system_clock::now();
std::chrono::nanoseconds t =time2- time1;
QCAT_CORE_INFO("Compute Shader time : {0}", t.count());
        
// Check The Cpu Calculation time
std::vector<Data> dataC(32);
time1 = std::chrono::system_clock::now();
for (int i = 0; i < 32++i)
{
    dataC[i].v1 = (dataA[i].v1 + dataB[i].v1);
    dataC[i].v2 = (dataA[i].v2 + dataB[i].v2);
}
time2 = std::chrono::system_clock::now();
= time2 - time1;
QCAT_CORE_INFO("CPU time : {0}", t.count() );
 
std::vector<char> data(sizeof(Data) * 32);
OutputBufferA->ReadData(data);
 
std::ofstream outfile("output.txt");
Data* pData = reinterpret_cast<Data*>(data.data());
for (int i = 0; i < 32++i)
{        
    outfile << "( V1: " << pData[i].v1.x << "," << pData[i].v1.y << "," << pData[i].v1.z
        << ") ( V2 :" << pData[i].v2.x << "," << pData[i].v2.y << ")" << std::endl;
}
 
outfile.close();
cs

코드를 위에서부터 찬찬히 살펴보면 처음에 벡터 두개를 선언하고 내용을 채워줍니다. Data 구조체는 float3,float2를 멤버로 가지고있는 구조체입니다.

그후 DX의경우 StructuredBuffer 3개를 만들고 한개는 Unordered Access View로 접근하도록 만듭니다. (ReadWrite부분) OpengL의경우 3버퍼모두다 SSBO 오브젝트를 만들고 2개의 오브젝트만 내용을 채워넣습니다.

입력 버퍼는 위에서 채워넣은 벡터의 내용으로 초기화합니다.

그후 컴퓨트 셰이더 바인딩후 입력버퍼2개와 출력버퍼1개를 바인딩합니다. 위에서 출력버퍼의 바인딩슬롯이 0인이유는  hlsl 에서는 구조적버퍼와 읽기/쓰기 구조적버퍼의 슬롯이 나눠있기때문입니다. 구조적버퍼의 경우 t0 이며 읽기/쓰기 버퍼의경우 u0 부터시작합니다. (SSBO를 사용할경우 buffer 슬롯이 0,1,2 이런순으로 지정해야겠죠?)

그후 컴퓨트 셰이더를 호출합니다. 간략하게나마 실행시간을 측정해보았는데. 이게 정확한 실행시간 측정방법인지는 모르겠습니다. 다만 셰이더 호출후에 CPU로 연산하는 부분의 실행시간도 계산합니다. 그리고 계산된 출력버퍼의 내용을 ReadData 함수를통해 읽어서 파일로 기록합니다.

이런 과정입니다. 딱 이 코드뿐입니다. 

출력버퍼에서 내용을 읽는부분만 설명하고 결과에대해서 이야기하겠습니다. 

DirectX에서는 버퍼의 내용을 읽으려면 어떻게 했어야했을까요? 이전에 ID값을 얻어온 방법처럼 D3D11_USAGE_STAGING 플래그를 가진 버퍼를 준비하여 값을 복사한후에 Map,UnMap을 통해 매핑된 포인터로부터 값을 읽어오면됩니다.

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
void DX11RWStructureBuffer::ReadData(std::vector<char>& data)
{
    D3D11_BUFFER_DESC outputDesc;
    outputDesc.Usage = D3D11_USAGE_STAGING;
    outputDesc.BindFlags = 0;
    outputDesc.ByteWidth = this->dataSize * this->count;
    outputDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
    outputDesc.StructureByteStride = this->dataSize;
    outputDesc.MiscFlags = D3D11_RESOURCE_MISC_BUFFER_STRUCTURED;
 
    ID3D11Buffer* outputBuffer = nullptr;
    QGfxDeviceDX11::GetInstance()->GetDevice()->CreateBuffer(&outputDesc, 0&outputBuffer);
    QGfxDeviceDX11::GetInstance()->GetContext()->CopyResource(outputBuffer, pBuffer.Get());
 
    int size = this->dataSize * this->count;
    if (data.size() != size)
        data.resize(size);
 
    D3D11_MAPPED_SUBRESOURCE mappedData;
    QGfxDeviceDX11::GetInstance()->GetContext()->Map(outputBuffer, 0, D3D11_MAP_READ, 0&mappedData);
 
    char* pData = reinterpret_cast<char*>(mappedData.pData);
    for (int i = 0; i < size++i)
        data[i] = pData[i];
    QGfxDeviceDX11::GetInstance()->GetContext()->Unmap(outputBuffer, 0);
}
cs

이렇게 새로운 버퍼를 만드는데 물론 중요한것은 해당 버퍼도 구조적버퍼임을 알리는 플래그릉 지정합니다. 생성후 copyResource 함수를통해 모든값을 복사한후에 Map,UnMap함수를 이용하여 처음위치의 포인터값을 가져옵니다. 사이즈는이미 알고있으므로 char 포인터로 변환하여 1byte씩 데이터를 가져와 벡터에 담아서 원하는 정보로 캐스팅합니다.

OpenGL의 경우도 Map,UnMap과 같은 함수가 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
void OpenGLShaderBuffer::ReadData(std::vector<char>& data)
    {
        int size = this->dataSize * this->count;
        glBindBuffer(GL_SHADER_STORAGE_BUFFER, m_renderID);
        char* pData = reinterpret_cast<char*>(glMapBufferRange(GL_SHADER_STORAGE_BUFFER, 0size, GL_MAP_READ_BIT));
 
        for (int i = 0; i < size++i)
            data[i] = pData[i];
glUnmapBuffer(m_renderID);
        //glMapNamedBufferRange(m_renderID,)
        glBindBuffer(GL_SHADER_STORAGE_BUFFER, 0);
    }
cs

 

 

버퍼를 바인딩하고 glMapBufferRange 함수를통해 범위데이터의 첫 주소를 받습니다. 그후 UnMap을통해서 매핑된 포인터를 무효화 시킵니다.

이렇게 구성하면 두벡터의 합을 계산하여 출력벡터에 기록하는 컴퓨트 셰이더 연산이 이루어지게됩니다.

현재 각 32개의 벡터들을 합하여 출력버퍼에 저장하는데 결과는 아래와 같습니다.

왼쪽은 DX컴퓨트셰이더 오른쪽은 GL입니다. CPU 타임은 단순히 계산했을때 지나간 시간입니다 (nanosecond) DX의 경우 11400 나노초나 지났지만 gl의경우 800나노초만 지났습니다. 결과는 똑같았구요.. 왜 DX가 cpu보다 더 느린지는 정확하게는 모르겠다만 제가 한방식으로 실행시간을 계산하면 렌더커맨드가 실행되는 시간까지 포함되야하는 걸까요?

벡터 정보의 갯수를 들려 3200개인 100배 더 늘려보도록 하겠습니다. 그리고 쓰레드 그룹의 개수도 100개로 지정해보아요(쓰레드 개수는 32개로 동일)

확실히 cpu 계산시간은 엄청나게 증가했습니다. 그에반해 ComputeShader 계산시간은 DX는 변동이없고 GL은 3배정도 더 들었네요. 제생각에는 저 10000나노초가 DX 렌더커맨더를 수행하는데 걸린시간이 아닐까.. 조심스럽게 예측해봅니다.

데이터개수는 25600 개 쓰레드개수는 256개 쓰레드그룹은 100개로 지정해보겠습니다.

CPU타임은 2백만 나노초임에 컴퓨트쉐이더는 각각 나노초가 거의 변동이 없는 수준입니다.

1000000 나노초 = 0.001 초 이므로 2백만 나노초면 0.002초정도 CPU에서 걸린거고 

DX는 0.00002초 GL은 0.000002초 정도 걸렸네요 DX 쪽에서왜 1만이나 더 드는건지.. 알아봐야겠습니다.

일단 이렇게 컴퓨트 셰이더에대한 기초적인 활용을 해보았습니다. 물론 이렇게 작은 데이터를 처리하는데 컴퓨트 셰이더를 사용하기에는 아깝겠죠. 엄청나게큰 텍스쳐처리라던지 방대한 데이터를 병렬적으로 처리하는데 사용하면 더욱더 뚜렷한 차이가 날것이라고 생각합니다!

마지막으로 앞으로 컴퓨트 셰이더를 적용하면서 알게될 여러 키워드가 있습니다. 지금당장은 자세하게 알지 못하는 키워드입니다.

-OpenGL-

glMemoryBarrier()  - 이 함수는 아직 정확하게 어떻게 작동하는지 잘 모르겠습니다 위키에서는 메모리 전송의 순서를 정해주는 배리어를 정의한다고 나와있습니다. 배리어 이후에 실행된 메모리 트랜잭션과 관련된 명령 이전에 실행된 메모리 트랜잭션을 정렬해주는 배리어를 정의해준다고 나와있는데 어떤역할인지 잘 모르겠네요

그리고 glsl 에서 사용되는 함수

barrier() - 이함수는 모든 스레드 호출이 이 함수호출시점에 다다들떄까지 대기하는함수입니다. 컴퓨트 셰이더에서만 호출가능합니다.(테셀레이션이랑) 

memoryBarrier() - 이지미나 원자연산 사용으로인한 메모리 액세스가 완료될떄까지 기다립니다. 함수 호출이전에 수행된 변수를 사용하여 수행된 메모리 저장소의 결과는 다른쎼이더호출에서 동일한 주소에대한 향후 일관된 메모리 액세스를 가능하게 합니다. 특히 한셰이더 단계에서 이러한 방식으로 작성된 값은 후속 단계에서 셰이더 호출에의해 수행되는 일관된 메모리 액세스에서 볼 수 있도록 보장합니다.

즉.. 번역은 저렇게되지만. 간단하게 요약하자면 컴퓨트 셰이더 연산을통해 메모리 액세스가 발생하고 내용이 바뀌거나하면 그러한 변경내용이 다른 스레드나 쉐이더 호출에서 해당 내용이 보이도록하는 키워드인것같습니다.

아래 Shared,image,buffer는 각각의 자원에대한 메모리 배리어이므로 공유메모리에대한 메모리배리어 이미지에대한 메모리배리어그리고 버퍼에대한 메모리배리어입니다. 제가 정확하게 이해했다면 변경된 내용이 다른 스테이지나 스레드에의해 보여져야한다는 것같습니다.

memoryBarrierShared(), memoryBarrierImage(), memoryBarrierBuffer() 각각에 대해서만 적용되는 배리어

groupMemoryBarrier() 

 

DX 에서는 GroupMemoryBarrierWithGroupSync()  함수가 있으며 그룹안의 모든스레드가 해당 함수까지의 작업을 완수할떄까지 기다리는 함수입니다.

GroupMemoryBarrier 함수는 그룹내의 스레드가 공유메모리 액세스를 완료할떄까지 모든스레드들이 기다립니다.즉 dX 정보까지 종합해보면 barrier는 말그대로 여러개의 스레드들이 해당 함수까지 작업을 완수할떄까지 기다리는 함수라고 생각합니다. 그리고 memoryBarrier는 데이터의 접근이 해당함수까지 모든 스레드에서 데이터액세스가 이루어지도록 기라려주는 함수라고 생각해봅니다..

컴퓨트 셰이더는 아직도 복잡해서 명확하게 개념이 잡히지 않은것같습니다. 

다음에는 이러한 컴퓨트 셰이더를 사용하여 DX11 루나책에있는 블러효과를 구현해보면서 실제 이미지에 컴퓨트 셰이더를 적용하는법을 알아보도록 하겠습니다.