6. ConstantBuffer
1. Contant buffer가 필요한 이유
2. Constant buffer로 이미지 이동시키기
1) GPU에서 사용할 수 있는 상수 데이터를 담는 버퍼를 정의 하고, b0 레지스터에 바인딩 한다.
2) VS에서 cbuffer TransformData의 구성요소인 offset을 사용한다.
3) CreateContantBuffer
4) CPU가 쓴 데이터를 GPU에 넘긴다.
5) Render의 VS단계에서 ConstantBuffer를 세팅하고, 실행한다.
1. Contant buffer가 필요한 이유
게임 수학을 배운 뒤 많이 사용할 상수 버퍼에 대해 이야기를 해보자.
vertex shader단계에서 변수를 사용하고 싶을 때 꽂아서 붙일 수 있는 그런 아이라고 볼 수 있다.
이게 왜 필요할까?
CreateGeometry를 다시 한번 살펴 보면
void Game::CreateGeometry()
{
// VertexData - CPU 메모리에 있는 거
// 1 3
// 0 2
{
_vertices.resize(4);
_vertices[0].position = Vec3(-0.5f, -0.5f, 0.f);
_vertices[0].uv = Vec2(0.f, 2.f);
// _vertices[0].color = Color(1.f, 0.f, 0.f, 1.f);
_vertices[1].position = Vec3(-0.5f, 0.5f, 0.f);
_vertices[1].uv = Vec2(0.f, 0.f);
// _vertices[1].color = Color(1.f, 0.f, 0.f, 1.f);
_vertices[2].position = Vec3(0.5f, -0.5f, 0.f);
_vertices[2].uv = Vec2(2.f, 2.f);
// _vertices[2].color = Color(1.f, 0.f, 0.f, 1.f);
_vertices[3].position = Vec3(0.5f, 0.5f, 0.f);
_vertices[3].uv = Vec2(2.f, 0.f);
// _vertices[3].color = Color(1.f, 0.f, 0.f, 1.f);
}
이런 식으로 기하 도형에 대한 모형 등등을 잡아줬다.,
움직이는 플레이어 같은 존재라고 하면, 매 틱마다 이동 한다거나 할때
키보드 입력에 따라 vertex의 위치를 조정하면 될까?
클라이언트에서 GPU 없이 작업할 땐 단순했다. Update에 델타틱 같은거 하나 받아서 그거에 따라 방향, 이동 거리 곱해서 그냥 정점에 더해주면 됐다.
Vertex를 어떻게 작업했나 보면 CreateGeometry에서 _vertices를 1차적으로 만들어주자 마자, vertex buffer를 만드는데 USAGE_IMMUTABLE로 만들었다. CPU는 접근 못하고 GPU는 read only 였다.
그럼 만약 이동할 때는? 어떻게 하지?
VertexBuffer에서 넘겨주는 정보를 한번 설정하면 그 뒤로 왜 변화가 없을까?
플레이어의 모델링을 만들었다 가정해보자. 이것에 대한 기하학적 정보가 GPU의 메모리에 들어갈거야. 근데 플레이어가 이동하면 계속 좌표가 이동할텐데 하는 생각이 든다.
하지만 실질적 VertexBuffer에 복사하는 순간 더이상 IMMUTABLE이라 고칠 수 없어라는 힌트를 줬어.
왜 이렇게 할까?
모형 자체는 고정하고, 옆으로 이동 한다면 케어가 안된다.
처음에 GPU에 넣어주는 기하학적 도형은 최초의 기하학적 모습 자체를 건내준다고 보면 된다.
사과가 전체 사과모형으로 이동할지언정 모양이 변하지 않기 때문에
사과의 좌표를 이동해서 10정도 움직일 때는 어떻게 해야 할까? 좌표 전체를 하나하나 옮기는 게 아니라. 얼마큼 이동해야 할지를 추가적인 정보를 받아서 이동하면 된다. 이렇게 하면 장점이 MMO에서 플레이어가 1000명이 있을 때 매 플레이어의 좌표가 확정 될 때 마다 Geometry 좌표를 매번 바꿔서 꽂아 준다면 속도가 느리겠지만, Vertex버퍼를 한번 만들어 주고, 버텍스 쉐이더에서 세부적인 아이들 위치만 조절할 수 있다면, 굳이 기하학적 도형에 손을 대지 않고도 얘를 이동시킬 수 있다는 얘기가 된다.
처음엔 이해가 안가니 실습을 해보자.
2. Constant buffer로 이미지 이동시키기
Default.hlsl의 VS에서 정보를 꽂아 넣을 수 있다. VS_INPUT 으로 들어 오는 건 기하학적 정보다.
함수에다 인자를 받아준 것 처럼 추가적인 정보를 받아줄 수 있는데 다음과 같이 만들어 주면 된다.
Default.hlsl에
1) GPU에서 사용할 수 있는 상수 데이터를 담는 버퍼를 정의 하고, b0 레지스터에 바인딩 한다.
cbuffer TransformData : register(b0) // 상수 버퍼 TransfromData를 받아 줄 건데, b0를 받아 준다. b는 buffer의 약자다.
{
float4 offset;
// 이걸 CPU가 세팅을 해서 TranformData를 넘겨주게 된다.
}
코드를 분석해 보면
- cbuffer TransformData : register(b0)
- cbuffer는 "constant buffer"의 줄임말로, GPU에서 사용할 상수 데이터를 담는 구조체를 정의합니다. 이 구조체는 셰이더에 전달되는 데이터를 포함하며, 그래픽스 파이프라인의 여러 단계에서 사용될 수 있습니다.
- TransformData는 이 상수 버퍼의 이름입니다.
- register(b0)는 이 버퍼가 셰이더의 b0 레지스터에 바인딩될 것임을 나타냅니다. 셰이더 레지스터는 GPU 메모리 내의 특정 위치를 가리키며, 여기서는 b0 레지스터가 상수 버퍼에 사용됩니다.
- float4 offset;
- float4는 4개의 부동 소수점 숫자를 포함하는 벡터 타입입니다. 여기서 offset은 4D 벡터 변수로, x, y, z, w 값을 포함합니다.
- 이 offset 변수는 셰이더에서 사용될 수 있는 데이터를 담고 있으며, 그래픽 변환과 관련된 계산에 사용될 수 있습니다. 예를 들어, 객체의 위치를 변화시키는 데 사용될 수 있습니다.
2) VS에서 cbuffer TransformData의 구성요소인 offset을 사용한다.
register에다가 넣어놓기로 했기 떄문에 offset을 그대로 사용할 수 있다.
그럼 실질적으로 VS에서 input.position에다가 offset만큼 더해주도록 할거야.
VS_OUTPUT VS(VS_INPUT input)
{
VS_OUTPUT output;
output.position = input.position + offset;
output.uv = input.uv;
return output;
}
여기서 input.position과 input.uv는 기하학적인 도형 자체의 값이기 떄문에 얘가 변하는게 아니라, 추가적으로 인자로 설정할 수 있는, 함수의 인자로 넣어줄 수 있는게, 추가적인 상수 버퍼에 넣어준 offset이란 얘기가 된다.
3) CreateContantBuffer
Game클래스에 돌아가서, void CreateContantBuffer(); 라는 함수를 선언한다.
Init에서 호출하는 코드를 적는다.
그리고 구현을 한다.
void Game::CreateContantBuffer()
{
D3D11_BUFFER_DESC desc;
ZeroMemory(&desc, sizeof(desc));
desc.Usage = D3D11_USAGE_DYNAMIC; // CPU_Write + GPU_Read
desc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
desc.ByteWidth = sizeof(TransformData);
}
USAGE_DYNAMIC으로 해줬다.
TransformData가 아직 없기 때문에 Struct.h로 가서 만들어 준다.
struct TransformData
{
Vec3 offset;
float dummy;
// 컨스턴트 버퍼를 만들 때는 16바이트 정렬을 해야하기 때문에
};
CreateContantBuffer로 다시 가서,
void Game::CreateContantBuffer()
{
D3D11_BUFFER_DESC desc;
ZeroMemory(&desc, sizeof(desc));
desc.Usage = D3D11_USAGE_DYNAMIC; // CPU_Write + GPU_Read
desc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
desc.ByteWidth = sizeof(TransformData);
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; // CPU도 접근할 수 있다.
HRESULT hr = _device->CreateBuffer(&desc, nullptr, _constantBuffer );
}
이렇게 해준다.
_constantBuffer가 없기 떄문에 Game.h에 가서 선언해준다. _transformData도 선언해준다.
private:
TransformData _transformData;
ComPtr<ID3D11Buffer> _constantBuffer;
이 아이를 일단은 constant buffer라고 부르도록 한다.
void Game::CreateContantBuffer()
{
D3D11_BUFFER_DESC desc;
ZeroMemory(&desc, sizeof(desc));
desc.Usage = D3D11_USAGE_DYNAMIC; // CPU_Write + GPU_Read
desc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
desc.ByteWidth = sizeof(TransformData);
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE; // CPU도 접근할 수 있따.
HRESULT hr = _device->CreateBuffer(&desc, nullptr, _constantBuffer.GetAddressOf());
CHECK(hr);
}
GetAddressOf()까지 쓰고, CHECK도 해준다.
이렇게 CPU가 write할 수 있고, GPU가 read할 수 있는 특이한 버퍼를 만들었다.
4) CPU가 쓴 데이터를 GPU에 넘긴다.
그럼 이거에 write할 수 있다고 했으니까 뭔가 쓸 수 있어.
그렇다고 buffer에 바로 집어 넣는 건 아니고 방식이 있다.
Update함수에서 매 프레임마다 constant buffer에 들어가는 TransformData가 사실상 플레이어의 SRT라고 하는 Scale, Rotation, Translation 같은 건데, 현재 월드 기준의 위치를 offset에 넣어주도록 할거야. 지금은 아무 값이나 넣어주고 있지만 나중에는 유의미한 행렬 값이나 뭔가를 넣어주게 될 것이다.
넣어준 어떤 값을 매 프레임마다 constant buffer라는 상수 버퍼에 복사를 해주고 싶은데, 그것을 어떻게 해줘야 할까?
Update함수로 가서 Map이랑 Unmap 세트를 이용하면 된다.
Map을 이용해서 뚜껑을 열어주듯이, 여기에 데이터를 넣어줄 준비를 하고,
Data를 복사를 한 다음에 Unmap을 해서 맵 했던거를 해제하는 식으로 해주면 된다.
void Game::Update()
{
D3D11_MAPPED_SUBRESOURCE subResource;
ZeroMemory(&subResource, sizeof(subResource));
_deviceContext->Map(_constantBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &subResource);
::memcpy(subResource.pData, &_transformData, sizeof(_transformData));
_deviceContext->Unmap(_constantBuffer.Get(), 0);
}
Map과 UnMap 사이에 데이터를 복사해 주면 된다.
코드를 분석해 보면
- 상수 버퍼 업데이트: _transformData.offset.x와 _transformData.offset.y를 변경하여 변환에 관련된 데이터를 업데이트합니다. 이 데이터는 셰이더에 전달되어 3D 오브젝트의 위치 변화 등에 사용됩니다.
- Map 함수 호출: _deviceContext->Map(...) 함수를 사용해 GPU 메모리에 있는 _constantBuffer를 CPU가 접근할 수 있는 subResource 메모리 영역에 매핑합니다. D3D11_MAP_WRITE_DISCARD 플래그는 기존 버퍼 내용을 덮어쓰겠다는 의미로, 성능 향상을 위해 사용됩니다.
- 데이터 복사: memcpy(subResource.pData, &_transformData, sizeof(_transformData)); 구문을 통해 CPU 메모리의 _transformData를 매핑된 GPU 메모리(상수 버퍼)에 복사합니다. 이 과정은 사실상 _constantBuffer에 데이터를 쓰는 것과 같습니다. 왜냐하면 subResource.pData는 _constantBuffer의 메모리에 대한 포인터이기 때문입니다.
- Unmap 함수 호출: 데이터 복사가 완료된 후, _deviceContext->Unmap(...)을 호출해 매핑을 해제합니다. 이는 CPU가 데이터 쓰기를 마쳤고, 이제 GPU가 상수 버퍼를 셰이더에서 다시 사용할 수 있음을 의미합니다.
뚜껑이 열린 상태로, subResource 이 데이터에다가 데이터를 복사한 다음에, 뚜껑을 닿아버리면, subResource가 연결이 되어 있는 상태이기 떄문에 subResource에 있는 데이터가 GPU쪽으로 복사가 된다. 즉 CPU에서 GPU로 데이터를 복사할 때 이런 방식으로 해주면 된다.
어떠한 데이터를 복사하냐는 _transformData에 들어있던 정보를 그냥 그대로 매핑된 데이터에다가 복사해 주고 싶다고 가정을 해 보자.
CPU가 들고 있던 데이터를 GPU에게 떠넘기는 것 까지 일단 성공했다.
void Game::Update()
{
_transformData.offset.x = 0.3f;
_transformData.offset.y = 0.3f;
D3D11_MAPPED_SUBRESOURCE subResource;
ZeroMemory(&subResource, sizeof(subResource));
_deviceContext->Map(_constantBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &subResource);
::memcpy(subResource.pData, &_transformData, sizeof(_transformData));
_deviceContext->Unmap(_constantBuffer.Get(), 0);
}
offset값을 이렇게 가정했다면,
셰이더에서
cbuffer TransformData : register(b0) // 상수 버서 TransfromData를 받아 줄 건데, 버퍼의 약자인 b0를 받아 주도록 할거야.
{
float4 offset;
// 이걸 CPU가 세팅을 해서 TranformData를 넘겨주게 된다.
}
여기서 받아줄 때,
VS_OUTPUT VS(VS_INPUT input)
{
VS_OUTPUT output;
output.position = input.position + offset;
output.uv = input.uv;
return output;
}
VS에서 offset을 더해준다고 했으니까, 포지션이 실제로 변하게 된다는 얘기가 된다.
그러기 때문에 위치를 조절해 줄 수 있다는 얘기가 된다.
5) Render의 VS단계에서 ConstantBuffer를 세팅하고, 실행한다.
ContatntBuffer를 만들어 줬으면 설정을 해줘야 하는데,
Render의 VS 단계에서
_deviceContext->VSSetConstantBuffers(0, 1, _constantBuffer.GetAddressOf());
이렇게 세팅해준다.
_constanBuffer가 GPU쪽에 있는 버퍼이긴 한데, CPU에서 GPU로 데이터를 밀어 넣어 줬고,
SetContantBuffers를 통해서 렌더링 파이프라인 단계에서 묶어준 상태이기 때문에
이제 셰이더에서
cbuffer TransformData : register(b0)
{
float4 offset;
}
얘를 사용할 준비가 끝났다는 얘기가 된다.
float4 offset을 받았으면 VS에서 사용하고 있는 것이니까,
실행을 해 보면
플레이어가 이동했다는 걸 볼 수 있다.
만약 Update에서
void Game::Update()
{
_transformData.offset.x += 0.003f;
_transformData.offset.y += 0.003f;
이렇게 해주면 플레이어가 우측상단으로 실시간으로 이동인다.
3. 맺음말
VS_INPUT에서 넣어 준 것은 고유 도형이니 수정하면 안되고, 냅둔 상태에서
VS_OUTPUT VS(VS_INPUT input)
{
VS_OUTPUT output;
output.position = input.position + offset;
이런 식으로 추가적인 정보(+ offset)만 끼워 넣어서, 물체를 이동시켜야 한다는게 핵심이다.
그냥 VertexBuffer의 값을 수정해서 다시 꽂아 주면 되는 것이지, 왜 굳이 상수 버퍼를 이용해서 덧셈을 해줘서 위치를 변경하는 것인지 이해를 잘 해야 한다.
몬스터가 1000마리 있다면 VertexBuffer의 기하학적 정보는 공용으로 쓰고,
상수 버퍼에 넣는 정보의 량은 도형 정보에 비해 작으니 꽂아 넣는다.
복습하면서 깨우침이 와야해. VertexBuffer는 왜 ReadOnly 로 만들었을까.
Constant Buffer에 추가적인 인자를 넣어서 VertexShader에서 합쳐서 고친다.
그렇게 하면 1000마리 되어도 , Vertex Buffer는 CPU에서 GPU로 한번 힘들게 복사한 그 정보를 계속 사용하는 것이고, 손 댈 필요가 없다.
언리얼이나 유니티에서 material이란 재질에 대해 추가적인 옵션을 선택할 수 있다. Diffuse 같은 세부적인 설정 값들이 이 상수 버퍼에 들어간다고 보면 된다 .
상수 버퍼를 사용할 준비가 끝났으니까 다음 시간엔 여기에 이런 저런 추가 정보인 행렬값, 수학 공식을 넣어서 셰이더에 적용을 시킬 것이다. 물체가 이동하거나, 커지거나, 회전하거나 효과를 얻을 수 있게끔 _transformData를 조작해주면 된다.
그래서 _transformData라고 부르는 것이고
유니티, 언리얼이고 공통적으로 로테이션, 트랜슬레이션, 위치랑 회전값 등을 들고 있다.
그걸 VertexShader 단계에 꽂아준다.
지금 단계에서 중요한 건 상수 버퍼라는 애를 만들어서,
거기에 매 프레임마다 데이터를 복사해서 건내주는
void Game::Update()
{
_transformData.offset.x += 0.003f;
_transformData.offset.y += 0.003f;
D3D11_MAPPED_SUBRESOURCE subResource;
ZeroMemory(&subResource, sizeof(subResource));
_deviceContext->Map(_constantBuffer.Get(), 0, D3D11_MAP_WRITE_DISCARD, 0, &subResource);
::memcpy(subResource.pData, &_transformData, sizeof(_transformData));
_deviceContext->Unmap(_constantBuffer.Get(), 0);
}
이 부분이 가장 핵심이라 볼 수 있다.
이 전체적인 흐름이 헷갈리긴 하겠지만 복습을 잘해야한다. 그래야 앞으로 나아갈 수 있다.
여기서 포기하면 강의를 더 들어도 의미가 없다.
유니티에서도 셰이더 작성하는 건 개념적으로 비슷하다.
상수 버퍼는 중요한 내용이다.
수학을 공부하고, 이를 조작하는 작업을 해볼 것이다.