정점 버퍼에 있는 정점 데이터들이 향하는 곳이 바로 정점 셰이더다. 셰이더는 HLSL(high level shading language, 고수준 셰이딩 언어)이라고 하는 언어로 작성했다. 이 언어는 문법이 C++와 비슷하기 때문에 예제를 보면서 알아봐도 무방하다. 먼저 파일을 따로 만들어야 하는데 그냥 평소 헤더나 cpp파일 만드는 곳 들어가서 hlsl 검색하면 나온다.

우리는 꼭짓점 먼저 만들거니까 꼭짓점 셰이더 파일을 사용한다.
다음은 예시이다.
cbuffer cbPerObject : register(b0)
{
float4x4 gWorldViewProj;
};
void VS(float3 iPosL : POSITION,
float4 iColor : COLOR,
out float4 oPosH : SV_POSITION,
out float4 oColor : COLOR)
{
oPosH = mul(float4(iPosL, 1.0f), gWorldViewProj);
oColor = iColor;
}
정점 셰이더는 마치 main 함수와 같이 하나의 함수 역할을 한다. 위의 예에서 VS라는 이름의 함수가 바로 정점 셰이더인데, 이름은 함수처럼 다른 이름을 써도 된다. 지금의 예시에서는 매개변수가 네 개인데, 앞에 두 개는 입력 매개변수이고, 나머지 둘은 출력변수이다. 어떻게 출력변수인걸 아냐고? out 키워드가 붙어있기 때문이다. 이는 마치 C++의 포인터나 참조 역할을 해서, 함수가 여러 개의 값을 돌려주고 싶다면 구조체나 이런 out 키워드를 사용한다.
Input Layout을 할 때 봤듯이, 매개변수 옆에 의미소 :POSITION과 :COLOR가 있다. 이는 정점 버퍼에 있는 자료와 각각 대응됐었다. 출력변수 옆에도 의미소가 있는데, SV_POSITION은 뭔가? SV는 system value(시스템 값) 의미소임을 뜻한다. 무슨 뜻인가? 이 의미소는 정점 셰이더의 이 출력이 정점의 위치를 담고 있음을 말해준다. 정점 위치는 정점의 다른 특성들은 관여하지 않는 연산들(예를 들면 절단 연산 등)에 쓰이기 때문에 다른 특성들과는 다른 방식으로 처리해야 한다. 이거 이외에는 의미소 이름을 아무렇게나(물론 가독성 있게) 지어도 가능하다.
이제 함수의 본문을 보자. 첫 줄(oPosH = mul(float4(iPosL, 1.0f), gWorldViewProj);)은 정점 위치에 4x4 행렬 gWorldViewProj를 곱해서, 정점을 국소 공간에서 세계 공간으로 변환한다. 일단 mul 함수가 float4, matrix 4x4 자료형을 매개변수로 받고, 이들의 곱을 반환한다. 근데 float4(iPosL, 1.0f) 이건 뭔가? float4(iPosL.x, iPosL.y, iPosL.z, 1.0f) 이거랑 같다. 물론 x, y, z는 float3의 각각의 원소이다. 정점의 위치는 벡터가 아니라 점이므로 넷째 성분을 1로 두었다.
그 다음 줄인 oColor = iColor;은, 그냥 입력된 색상을 출력 매개변수로 복사한다.
VS의 매개변수가 너무 많은 것 같은데, 이렇게 써도 된다.
cbuffer cbPerObject : register(b0)
{
float4x4 gWorldViewProj;
};
struct VertexIn
{
float3 PosL : POSITION;
float4 Color : COLOR;
};
struct VertexOut
{
float4 PosH : SV_POSITION;
float4 Color : COLOR;
};
VertexOut VS(VertexIn vin)
{
VertexOut vout;
vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj);
vout.Color = vin.Color;
return vout;
}
변수 gWorldViewProj가 들어있는 cbuffer은 뭔가? cbuffer은 constant buffer의 약자로 '상수 버퍼'라고 부른다. 정점 버퍼 이외에 셰이더에서 계산하기 편한 것들을 옮길 수 있는 수단이다. 행렬 계산은 셰이더에서 하는 게 더 유리하므로 세계 행렬, 시야 행렬, 투영 행렬을 보통 cbuffer를 통해 옮긴다. 일단 hlsl로 어떻게 버퍼를 보내는지 알아보자.
상수 버퍼는 정점 버퍼나 색인 버퍼처럼 하나의 버퍼이므로 CreateBuffer 함수를 통해서 생성한다. 파이프라인에 묶는 함수는 VSSetConstantBuffers로 정점 버퍼나 색인 버퍼와는 다르다. 생성부터 묶기까지 한번 알아볼 건데, 간단하게 CPU랑 GPU가 행렬을 대하는 방법이 다르다는 것부터 알아보자. CPU는 행렬을 대할 때 행우선으로 대한다. 이게 무슨 말인가? 예를 들어 4x4 행렬은 16개의 원소로 되어있다. 이 행렬에 원소를 대입할 때 (1,2,3,4,5....)이런식으로 대입한다고 하면 1열이 (1,2,3,4), 2열이 (5,6,7,8)... 이런 식으로 된다는 뜻이다. 반면 GPU는 열우선이다. 열우선은 원소를 대입할 때 (1,2,3,4,5....)이런식으로 대입하면, 1열이 (1,2,3,4), 2열이(5,6,7,8)... 뭐 이런식으로 된다는 거다. CPU랑 GPU의 이런 차이 때문에, 행우선으로 할 건지, 열우선으로 할 건지 통일이 필요하다. CPU와 GPU 둘 다 전치하는 방법이 있어서, 한 곳에서만 전치시켜주면 된다.(전치란 행렬의 대각성 성분을 기준으로 원소들을 맞바꾸는걸 뜻한다. 행우선 행렬을 전치시키면 열우선이되고, 열우선 행렬을 전치시키면 행우선이 된다.) 먼저 열을 우선으로 하는 방법을 알아보자.
GPU가 열우선이기 때문에, CPU에서 보내기 전에 열우선으로 만들어서 보내주면 된다. D3DXMatrixTranspose() 함수를 이용한다.
struct TransformData
{
D3DXMATRIX world;
D3DXMATRIX view;
D3DXMATRIX projection;
};
cpuBuffer = new TransformData();
D3DXMatrixTranspose(&cpuBuffer->world, &world);
D3DXMatrixTranspose(&cpuBuffer->view, &view);
D3DXMatrixTranspose(&cpuBuffer->projection, &projection);
D3DXMatrixTranspose 함수의 제1 매개변수는, 반환 받을 4x4행렬 포인터이다. 제2 매개변수는 반환 할 4x4행렬 포인터다. 그러니까 제2 매개변수를 열우선으로 전치시켜서 제1 매개변수에 넣겠다는 소리다. 다음은 행우선을 알아보자. CPU가 행우선이니, GPU에서 받은 걸 행우선으로 바꾸면 되겠다.
cbuffer TransformData : register(b0)
{
row_major matrix _world;
row_major matrix _view;
row_major matrix _proj;
}
이렇게 받을 행렬 앞에 row_major을 써주면 행우선으로 전치된다. 보통 첫 번째 방법을 많이 쓴다. 이제 전치하는 방법을 알았으니, 상수 버퍼를 GPU로 넘기는 방법을 알아보자.
D3D11_BUFFER_DESC desc;
ZeroMemory(&desc, sizeof(D3D11_BUFFER_DESC));
desc.Usage = D3D11_USAGE_DYNAMIC;
desc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
desc.ByteWidth = sizeof(TransformData);
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
HRESULT hr = md3dDevice::CreateBuffer(&desc, nullptr, &gpuBuffer);
assert(SUCCEEDED(hr));
정점 버퍼, 색인 버퍼때와는 다른 플래그를 사용하였다. 또한 D3D11_SUBRESOURCE_DATA 구조체를 딱히 작성하지 않았다. 무슨 의미인가?
일단 이 포스팅을 읽고 오자.
동적 정점 버퍼
데이터를 실행중에 수정하기 위해서 저렇게 썼다. 왜? 지금 보내는 행렬들은 매 프레임마다 바뀌는 데이터이기 때문이다. 그래서 D3D11_SUBRESOURCE_DATA도 쓰지 않았고, CreateBuffer 함수에서 이와 관련된 제2 매개변수도 nullptr로 넣었다.
생성한 버퍼를 파이프라인에 묶으려면? ID3D11DeviceContext::VSSetConstantBuffers 함수를 이용한다.
md3dImmediateContext->VSSetConstantBuffers(0, 1, &gpuBuffer);
이는 밑에 내용을 배우고 다시 보자.
이렇게 CPU의 자원을, 상수 버퍼를 통해 GPU로 보내는 방법을 배웠다. 다시 hlsl 코드로 돌아와보자.
cbuffer TransformData : register(b0)
{
matrix _world;
matrix _view;
matrix _proj;
}
이제 저 cbuffer 변수의 오른쪽에 붙은 : register(b0)은 무엇인가? register은 '버퍼의 정보를 더 효율적으로 받을 수 있게 만들어준다.'정도로만 알아두자. 그럼 뒤의 b0은 무엇인가? 이 hlsl로 보낼 수 있는 건 상수 버퍼 이외에 텍스처(t), 샘플러(s) 등이 있다. 그걸 구분하기 위해서 알파벳을 쓴다. 그럼 숫자는 무엇인가?
아까 VSSetConstantBuffers(0, 1, &gpuBuffer)에서 보낼 수 있는 건 사실 상수 버퍼 '배열'들이다. 그리고 이 함수의 제1 매개변수는, 그 배열의 시작 지점이다. 제2 매개변수는 개수이다. 그러니까 제1 매개변수 지점에서, 제2 매개변수의 크기만큼 보낼 수 있는 것이다. 제3 매개변수는 그 배열의 포인터이다. register(b0)의 숫자는 그 배열의 인덱스를 맞춰주면 된다. 지금은 gpuBuffer 배열 0번 지점에서, 1개만 보내는 거니, 배열 0번에 우리가 보내는 정보가 들어있는 것이다. 그래서 0으로 받아준다. 만약 VSSetConstantBuffers(2, 1, &gpuBuffer) 이렇게 쓴다면? 2번 인덱스부터 1개 보낸 거니까 register(b2)로 받아주면 된다. 상수 버퍼 배열의 크기는 14이다.
cbuffer을 효율적으로 만드는 방법은 무엇인가? → 내용을 얼마나 자주 갱신할 것인지에 근거를 두고 나누어 만들라는 것이다. 왜? 한 상수 버퍼를 갱신할 때는 그 상수 버퍼의 모든 변수를 갱신해야 하기 때문이다. 예를 들어 다음과 같이 세 개의 상수 버퍼가 있다.
cbuffer cbPerObject : register(b0)
{
float4x4 gWVP;
};
cbuffer cbPerFrame : register(b1)
{
float3 gLightDirection;
float3 gLightPosition
float4 gLightColor;
};
cbuffer cbPerObject : register(b2)
{
float4 gFogColor;
float gFogStart;
float gFogEnd;
};
첫 번째 상수 버퍼는 세계, 시야, 투영 행렬의 결합을 담는다. 이 변수는 물체에 따라 다르므로, 물체마다 갱신해야 한다. 예를 들어 프레임당 100개의 물체를 렌더링하면, 이 변수를 프레임당 100번 갱신해야 한다. 둘째 상수 버퍼는 장면 광원 정보를 담는다. 만약 광원이 움직이낟면, 이 버퍼 역시 애니메이션의 매 프레임마다 갱신해야 한다. 마지막 상수 버퍼는 안개를 제어하는 변수를 담았는데, 안개는 시시각각 변하지 않으므로 갱신율이 낮다.