DirectX가 중요하다는 사실을 누구나 알고 있다. 어디서 중요하다고 해서 그렇든 학원에서 가르쳐주니까 그렇든 중요한 건 누구나 알고 있지만 스스로 혼자 독학하긴 쉽지 않다.
그러니 나무를 보지 말고 숲을 봐야한다. DX의 경우 함수가 정말 많이 나오는데 함수가 어떤 역할을 하는지만 알고 인자 하나하나가 무엇인지 너무 고민하지 말아야 한다. 큰 틀을 공부하면 자연스레 깨달을 것이다.
장치 초기화
Direct3D 초기화의 시작은 Direct3D 11 장치(ID3D11Device)와 그 문백(ID3D11Device Context)를 생성하는 것이다.
- ID3D11Device 인터페이스는 기능 지원 점검과 자원 할당에 쓰인다.
- ID3D11DeviceContext 인터페이스는 렌더 대상을 설정하고, 자원을 그래픽 파이프 라인에 묶고, GPU가 수행할 렌더링 명령들을 지시하는 데 쓰인다.
이것을 클래스 상에서 일반적으로 포인터를 들고 있고 이걸 new 하거나 delete를 하지 않는다. 이는 생명주기가 꼬일수 있기 때문에 스마트 포인터를 이용해서 작업한다.
바로 ComPtr를 이용한다. ComPtr을 보면
복사하면 자동으로 Ref를 증가하거나 Release하는 방식이다. 그러니 약간 C# 처럼 동작할 수 있다는 것이다.
private:
// DX
ComPtr<ID3D11Device> _device;
ComPtr<ID3D11DeviceContext> _deviceContext;
우리가 생성하는 모든 것(이미지, 글자 등등)은 ID3D11Device를 통해서 생성한다. 즉 커멘드 센터인 셈이다. 명령을 내리는 것은 Context이다.
즉 유닛 생성은 Device가 유닛 명령은 Context가 한다.
Device를 생성하기 위해서 MS에 들어가 확인해보자.
CreateDevice나 CreateDeviceAndSwapChain을 사용하면 Device를 생성할 수 있다고 한다.
후자를 이용할 것인데 SwapChain가 무엇인지 알아야한다.
ComPtr<IDXGISwapChain> _swapChain = nullptr;
DXGI는 Direct3D와 함께 쓰이는 API로 DirectX 그래픽 런타임에 독립적인 저수준(Low-Level)의 작업을 관리한다. 또한 DirectX 그래픽을 위한 기본적이고 공통적인 프레임워크를 제공한다. DXGI는 유연성을 위해 새로운 그래픽 라이브러리가 나오더라도 변하지 않을 수 있도록 구성되어 있다.
IDXGISwapChain는 DirectX에서 SwapChain을 사용하기 위한 인터페이스이다.
SwapChain는 이미지를 표시하는데 사용되는 일련의 프레임 버퍼를 의미한다.
이는 더블 버퍼링으로 이루어져있는데 최 전선에서 화면에 보여주는 버퍼가 있고 그 뒤에 계산하는 버퍼가 있다.
만약 이중이 아니라 단일이였다면 화면에 어떤 그림을 보여주기 위해 버퍼를 채우는 와중에 다른 지시로 인해 버퍼를 수정하게 되면 화면에 이상한 값이 출력될 것이다.
그러니 화면에 보여주는 버퍼는 그냥 그대로 보여주고 백 버퍼에서 계산하고 끝나면 화면에 보여주게 하여 문제를 해결하는 것이다.
더블 버퍼링에는 크게 두가지 방식이 있다.
후면 버퍼에서 계산을 끝내면 전면 버퍼로 고속 복사 하는 방식이 있는가 하면(WinApi에서 이용)
후면 버퍼에서 계산을 끝내면 자기 자신이 전면 버퍼가 되는 방식이 있다.(서로 번갈아 가며 전면 버퍼의 역할과 후면 버퍼의 역할을 한다.)
말이 어렵지만 DXGI는 결국 수 많이 변하는 DirectX의 인터페이스중 잘 변하지 않는, 변하기 쉽지 않는 인터페이스만 따로 빼서 모아둔 것이다.
이제 진짜 Create 해볼 것인데
보면 알겠지만 이게 함수이다. 함수 인자가 어마어마한 것을 볼 수 있다.(이런거 때문에 DX를 포기하는 사람이 등장한다고 한다.)
그러니 이걸 하나하나 다 공부한다기 보다 필요한 경우가 생기면 하나씩 알아보는걸로 하자.
void Game::CreateDeviceAndSwapChain()
{
DXGI_SWAP_CHAIN_DESC desc;
ZeroMemory(&desc, sizeof(desc));
{
// 현재 화면 크기가 800 * 600 이니까
// 화면 버퍼 크기에 맞춰줌
desc.BufferDesc.Width = _width;
desc.BufferDesc.Height = _height;
desc.BufferDesc.RefreshRate.Denominator = 1;
desc.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
desc.BufferDesc.ScanlineOrdering = DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED;
desc.BufferDesc.Scaling = DXGI_MODE_SCALING_UNSPECIFIED;
// 멀티샘플링 개수 (계단 현상 해결)
desc.SampleDesc.Count = 1;
desc.SampleDesc.Quality = 0;
desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
desc.BufferCount = 1;
desc.OutputWindow = _hwnd;
desc.Windowed = true;
desc.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
}
HRESULT hr = ::D3D11CreateDeviceAndSwapChain(
nullptr,
D3D_DRIVER_TYPE_HARDWARE,
nullptr,
0,
nullptr,
0,
D3D11_SDK_VERSION,
&desc,
_swapChain.GetAddressOf(),
_device.GetAddressOf(),
nullptr,
_deviceContext.GetAddressOf()
);
CHECK(hr);
}
이걸 기반으로 추가적인 작업을 할 수 있는 것이다.
후면 버퍼에 뭔가를 해달라고 하기 위해 RenderTargetView가 필요하다.
// RTV
ComPtr<ID3D11RenderTargetView> _renderTargetView;
우리가 만든 후면 버퍼를 묘사하는 존재이다.
void Game::CreateRenderTargetView()
{
HRESULT hr;
// _swapChain의 후면 버퍼에 해당되는 부분을 텍스터의 형태로
// backBuffer에 저장하고
ComPtr<ID3D11Texture2D> backBuffer = nullptr;
hr = _swapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)backBuffer.GetAddressOf());
CHECK(hr);
// 유닛을 생성하는 device를 통해서 backBuffer를 이용해
// RenderTargetView를 발급해주는 것
_device->CreateRenderTargetView(backBuffer.Get(), nullptr, _renderTargetView.GetAddressOf());
CHECK(hr);
// 이렇게 되면 우리가 GPU랑 통신하면서 RenderTargetView를 건네주면
// 알아서 backBuffer에 그려주는게 된다.
}
이제 어느정도 장치를 초기화 했으니
흐름을 알아보면 Render를 할 때 Render를 시작 할때 일을 하고 Render를 진행한 다음 전부 다 완료했으니 End하면서 넘겨주는 방식이다.
void Game::RenderBegin()
{
_deviceContext->OMSetRenderTargets(1, _renderTargetView.GetAddressOf(), nullptr);
_deviceContext->ClearRenderTargetView(_renderTargetView.Get(), _clearColor);
_deviceContext->RSSetViewports(1, &_viewport);
}
그림을 전부 다 그렸다면 여기에 그려줘 라는 의미이다. renderTargetView를 건네주는 것이다.
void Game::RenderEnd()
{
// 렌더 제출하기
HRESULT hr = _swapChain->Present(1, 0);
CHECK(hr);
}
void Game::Render()
{
RenderBegin();
// TODO
RenderEnd();
}
지금 까지 잘 작성했다면 아마 회색화면이 나오면 된다.
이 말은 즉슨 우리가 빈 도화지를 만드는데 성공했다고 볼 수 있다. 렌더링 파이프를 다 통해서 작업을 한 것이다.(물론 아무것도 그리진 않음)
아직은 잘 모르지만 이것만 알고 있으면 된다.
- ID3D11Device -> 옵젝을 생성하는 애
- ID3D11DeviceContext -> 생성된 애를 조종하는 애(연결 시켜준다는 느낌)
- IDXGISwapChain -> 모니터에 그려주기 위해 더블 버퍼를 이용하기 위한 애
- ID3D11RenderTargetView -> 그 더블 버퍼의 후면 버퍼에 그림을 그리기 위한 애(Texture 같은거)
삼각형 그리기
지금까지 어떤 색상을 가진 화면까진 띄웠으니 삼각형을 그려보자.
간단한 삼각형 도형을 그리더라도 매우 복잡할 수 있다. 왜냐하면 간단한 도형일지언정 파이프라인의 과정을 하나도 빼먹으면 안되기 때문이다.
그렇기 때문에 모든게 완성이 되어야 작동하기에 뭐부터 만들어야할지 그리고 그렇게 하면 이해가 될지 잘 모르겠지만 일단 도형에 대한 기하학적인 내용 부터 만들어보자.
struct Vertex
{
Vec3 position;
Color color;
};
정점을 가지고 기하학적인 내용을 정의하기 위한 구조체이다.
private:
// Geometry
vector<Vertex> _vertices;
void Game::CreateGeometry()
{
// VertextData
{
_vertices.resize(3);
_vertices[0].position = Vec3(-0.5f, -0.5f, 0);
_vertices[0].color = Color(1.f, 0.f, 0.f, 1.f);
_vertices[1].position = Vec3(0.f, 0.5f, 0);
_vertices[1].color = Color(1.f, 0.f, 0.f, 1.f);
_vertices[2].position = Vec3(0.5f, -0.5f, 0);
_vertices[2].color = Color(1.f, 0.f, 0.f, 1.f);
}
}
이렇게 해서 삼각형이 정점을 적절한 위치에 배치할 수 있다.
유의해야하는 점이 있다. 지금 우리가 생성한 vector는 GPU(VRAM) 영역이 아니라 아직 CPU 영역 (RAM)에 있다.
그렇기 때문에 바로 사용할 수 없고 CPU영역에서 GPU영역으로 넘겨주어야만 한다.
따라서 버퍼를 이용해 넘겨준다.
vector<Vertex> _vertices;
ComPtr<ID3D11Buffer> _vertexBuffer = nullptr;
void Game::CreateGeometry()
{
// VertextData
{
_vertices.resize(3);
_vertices[0].position = Vec3(-0.5f, -0.5f, 0);
_vertices[0].color = Color(1.f, 0.f, 0.f, 1.f);
_vertices[1].position = Vec3(0.f, 0.5f, 0);
_vertices[1].color = Color(1.f, 0.f, 0.f, 1.f);
_vertices[2].position = Vec3(0.5f, -0.5f, 0);
_vertices[2].color = Color(1.f, 0.f, 0.f, 1.f);
}
// VectexBuffer
{
D3D11_BUFFER_DESC desc;
ZeroMemory(&desc, sizeof(desc));
// GPU read only
desc.Usage = D3D11_USAGE_IMMUTABLE;
desc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
desc.ByteWidth = (uint32)(sizeof(Vertex) * _vertices.size());
D3D11_SUBRESOURCE_DATA data;
ZeroMemory(&data, sizeof(data));
data.pSysMem = _vertices.data();
_device->CreateBuffer(&desc, &data, _vertexBuffer.GetAddressOf());
}
}
이렇게 해서 버퍼를 생성할 수 있다. (IA 과정)
그런데 여기까지는 컴퓨터가 지금 어떻게 묘사를 해야하는지 알 수 없다. 아직까진 GPU가 봤을 때, 데이터가 그냥 나열된 것에 불과하기 때문이다. 그렇기 때문에 그것을 우리가 알려주어야 한다. 가령 데이터를 어떻게 끊어 읽어야하며 분석해야하는지를 말이다.
CreateInputLayout를 통해 할 수 있다.
단, CreatInputLayout의 인자중 하나는 쉐이더 이기 때문에 쉐이더 역시 같이 구현해야한다.
그렇기 때문에 hlsl 파일을 만들어서 작업해야한다.
쉐이더를 정의해보자.
struct VS_INPUT
{
float4 position : POSITION;
float4 color : COLOR;
};
struct VS_OUTPUT
{
float4 position : SV_POSITION;
float4 color : COLOR;
};
// IA - VS - RS - PS - OM
// 그중 VS
VS_OUTPUT VS(VS_INPUT input)
{
VS_OUTPUT output;
output.position = input.position;
output.color = input.color;
return output;
}
float4 PS(VS_OUTPUT input) : SV_Target
{
return float4(1, 0, 0, 0);
}
이제 VS 과정과 RS과정을 하기 위해 쉐이더를 로드해보자.
// VS
ComPtr<ID3D11VertexShader> _vertexShader = nullptr;
ComPtr<ID3DBlob> _vsBlob = nullptr;
// PS
ComPtr<ID3D11PixelShader> _pixelShader = nullptr;
ComPtr<ID3DBlob> _psBlob = nullptr;
void Game::CreateVS()
{
LoadShaderFromFile(L"Default.hlsl", "VS", "vs_5_0", _vsBlob);
HRESULT hr = _device->CreateVertexShader(_vsBlob->GetBufferPointer(),
_vsBlob->GetBufferSize(), nullptr, _vertexShader.GetAddressOf());
CHECK(hr);
}
void Game::CreatePS()
{
LoadShaderFromFile(L"Default.hlsl", "PS", "ps_5_0", _psBlob);
HRESULT hr = _device->CreatePixelShader(_psBlob->GetBufferPointer(),
_psBlob->GetBufferSize(), nullptr, _pixelShader.GetAddressOf());
CHECK(hr);
}
쉐이더를 로드했으니 이게 가능하다.
void Game::CreateInputLayout()
{
D3D11_INPUT_ELEMENT_DESC layout[] = {
{"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0 ,0, D3D11_INPUT_PER_VERTEX_DATA, 0},
{"COLOR", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0 ,12, D3D11_INPUT_PER_VERTEX_DATA, 0},
};
const int32 count = sizeof(layout) / sizeof(D3D11_INPUT_ELEMENT_DESC);
_device->CreateInputLayout(layout, count, _vsBlob->GetBufferPointer(), _vsBlob->GetBufferSize(), _inputLayout.GetAddressOf());
}
이제 최종적으로 Render를 하기만 하면 된다.
void Game::Render()
{
RenderBegin();
// IA - VS - RS - PS - OM
{
uint32 stride = sizeof(Vertex);
uint32 offset = 0;
// IA
_deviceContext->IASetVertexBuffers(0, 1, _vertexBuffer.GetAddressOf(), &stride, &offset);
_deviceContext->IASetInputLayout(_inputLayout.Get());
_deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// VS
_deviceContext->VSSetShader(_vertexShader.Get(), nullptr, 0);
// RS
// PS
_deviceContext->PSSetShader(_pixelShader.Get(), nullptr, 0);
// OM
_deviceContext->Draw(_vertices.size(), 0);
}
RenderEnd();
}
요런식의 결과를 얻을 수 있다.
결국에 지금까지 한건 렌더를 하기 위한 준비과정중에 하나인 것이다.
결론은
Input Assembler에서는 CPU가 물체의 정점들의 정보를 정점 버퍼로 변환하여 GPU로 넘겨주고 GPU는 받은 정점 버퍼를 정점 데이터로 조립하여 삼각형과 같은 기본 도형으로 만들어 준 뒤 Vertex Shader로 넘겨주게 된다. Vertex Shader에서는 정점 데이터를 토대로 공간 좌표계를 변환시키는데, Local Space에서 World Space, View Space, 최종적으로 투영공간인 Clip Space로 변환시킨 정점 데이터를 출력하여 넘겨준다. 다음으로는 Raterization 단계에서는 3D상에 표현된 물체를 2D상 좌표인 Screen Space로 변환하고, 물체를 픽셀로 분해해주는 과정이다. 이후에 Pixel Shader에서는 렌더링 될 각각의 픽셀에 입힐 색상들을 계산하여 최종적으로 Output Merge 단계에서 화면에 그려질 픽셀을 정해 색상을 입혀 화면에 출력하게 된다.
코드로 따져보면
(1) CreateGeometry()를 통해 정점 정보를 생성하고 GPU가 읽을 수 있는 버퍼를 생성한다.
(2) VS를 하기위에 CreateVS()를 통해 Vertex 쉐이더 파일을 가져와 저장한다.
(3) 우리가 정점 정보를 생성하긴 했지만 이를 어떻게 분석할것인지에대해 알려주기위해 CreateInputLayout()를 한다.
(4) 픽셀마다 색상을 처리하기 위해 CreatePS()를 통해 쉐이더를 저장한다.
이러면 렌더할 준비가 끝났고
렌더를 할 때,
IA 과정에서 (1)에서 만든 버퍼와 (3)에서 만든 레이아웃을 넣어준다.
VS 과정에서 (2)번에서 만든 쉐이더를 넣어준다.
PS 과정에서 (3)번에서 만든 쉐이더를 넣어준다.
화면에 보여준다.
이런 느낌이다.
'C++ > DirectX 11' 카테고리의 다른 글
[DirectX] 프레임워크 제작기 - Graphics, IA, Geomotry (1) | 2024.09.02 |
---|---|
[DirectX] 행렬 - SRT 변환 행렬과 좌표계 변환 행렬 및 예제 (0) | 2024.08.31 |
[DirectX] DirectX 11 입문하기 - Constant Buffer와 State (0) | 2024.08.29 |
[DirectX] DirectX 11 입문하기 - 텍스쳐와 UV (2) | 2024.08.28 |
[DirectX] DirectX 11 입문하기 - 그래픽스 OT, 기본 프레임 워크 (0) | 2024.08.27 |