이 상태에서는 Depth Test와 Stencil Test가 기본 설정으로 실행되는데, 이걸 사용자 임의로 조절할 수가 있습니다. 이를 위해 DirectX에서는 DepthStencilState를 지원해줍니다.
- DepthStencilView : 어떤 깊이 데이터를 쓸지 GPU에 알려주는 버퍼
- DepthStencilState : 이제 이 버퍼 데이터 어떻게 쓸것인지, "사용 설명서"
두 가지 모두 바인딩되어야 GPU가 Z-Test, Z-Culling, Stencil Test 등을 사용자가 원하는 방식대로 진행할 수 있습니다.
[DepthStencilState 생성]
일단 DepthStencilState 값을 정의해줍니다.
// DirectX11 DepthStencilState 생성
void Ext_DirectXResourceLoader::MakeDepth()
{
D3D11_DEPTH_STENCIL_DESC Desc = { 0, };
Desc.DepthEnable = true;
Desc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL;
Desc.DepthFunc = D3D11_COMPARISON_LESS_EQUAL;
Desc.StencilEnable = false;
/*1. 깊이 테스트 수행함(true)*/
/*2. 깊이 값을 Z버퍼에 기록*/
/*3. 새 픽셀이 더 가깝거나 같으면 통과*/
/*4. 스텐실 안함*/
Ext_DirectXDepth::CreateDepthStencilState("EngineDepth", Desc);
// EngineDepth 이름을 리소스 매니저에 저장하고, CreateDepthStencilState() 호출
}
DepthStencilState 값은 D3D11_DEPTH_STENCIL_DESC를 통해 정의할 수 있으며, 여기서 활용한 값들의 설명은 다음과 같습니다.
DepthEnable
- Depth를 쓰는지 여부 - true 전달(씀)
DepthWriteMask
- D3D11_DEPTH_WRITE_MASK_ALL - 깊이 값을 Z버퍼에 기록한다는 뜻
DepthFunc
- D3D11_COMPARISON_LESS_EQUAL - 새 픽셀이 더 가깝거나 같으면 통과
> 만약 State를 정의하지않고, 디폴트를 사용하고 있었다면 해당 부분이 D3D11_COMPARISON_LESS로 설정되어 Depth Test가 실행
StencilEnable
- Stencil을 쓰는지 여부 - false 전달(안씀)
사실 사용할 DepthStencilState가 디폴트 State와 크게 차이가 없습니다. 한 가지 다른건 DepthFunc인데, 기존에는 D3D11_COMPARISON_LESS였지만 여기서는 D3D11_COMPARISON_LESS_EQUAL로 설정했습니다. 둘의 차이는 다음과 같습니다.
D3D11_COMPARISON_LESS(기본값)
- 일반적으로 많이 사용 - 완전히 앞에 있는 픽셀만 통과 - 깊이 버퍼에 동일한 값이 있으면 새로운 픽셀은 버려짐
D3D11_COMPARISON_LESS_EQUAL(설정값)
- 픽셀의 깊이가 기존과 같은 경우에도 그림 - 여러 번 동일한 깊이로 그릴 때 필요 > 같은 깊이에 여러 Pass가 겹칠 때 필요 - 잘못 사용하면 중복 필셀로 Z-Fighting 발
여기서 D3D11_COMPARISON_LESS_EQUAL로 설정한 이유는 나중에 렌더링 과정을 Deferred 렌더링으로 변경하기 위해서 입니다. 디퍼드 렌더링에서는 동일한 깊이값에 여러 Pass가 렌더링될 수 있는데, 그 때를 대비해서 이렇게 먼저 선언해 두었습니다.
추가로 Stencil을 기존과 마찬가지로 사용하지 않는 것으로 설정했습니다. 나중에 마스킹을 할 때 사용할 수도 있긴 한데, 그때가 되면 그냥 MRT를 사용할 수도 있어서 그냥 마스킹될 대상을 따로 그리는 방식을 취할 수도 있습니다.
이제 CreateDepthStencilState()를 호출하면 DSS를 만들 수 있습니다. 이러면 Ext_DirectXDepth 클래스 내부로 들어가 다음의 함수를 실행할 것입니다.
// 위에서 전달받은 정보를 통해 CreateDepthStencilState()로 DSS 생성
static std::shared_ptr<Ext_DirectXDepth> CreateDepthStencilState(const std::string_view& _Name, const D3D11_DEPTH_STENCIL_DESC& _Desc)
{
std::shared_ptr<Ext_DirectXDepth> NewDepth = Ext_DirectXDepth::CreateNameResource(_Name);
NewDepth->CreateDepthStencilState(_Desc);
return NewDepth;
}
// DSS를 GPU에 바인딩, 아웃풋머저에 적용
void Ext_DirectXDepth::Setting()
{
if (nullptr == DSS)
{
MsgAssert("깊이버퍼 스테이트가 만들어지지 않았습니다.");
}
Ext_DirectXDevice::GetContext()->OMSetDepthStencilState(DSS.Get(), 0);
/*1. 생성한 DSS 전달*/
/*2. 2번째 인자 0은 스텐실 안쓴다는 뜻*/
}
// DepthStencilState 생성
void Ext_DirectXDepth::CreateDepthStencilState(const D3D11_DEPTH_STENCIL_DESC& _Value)
{
DepthStencilInfo = _Value;
if (S_OK != Ext_DirectXDevice::GetDevice()->CreateDepthStencilState(&DepthStencilInfo, DSS.GetAddressOf()))
{
MsgAssert("깊이 버퍼 스테이트 생성에 실패했습니다.");
}
// D3D11_DEPTH_STENCIL_DESC(DSS 생성 설명서)를 바탕으로 DSS 생성
}
생성된 DepthStencilState는 Material에 바인딩해서 사용해주도록 합니다.
// 일반(단일 메시)
{
std::shared_ptr<Ext_DirectXMaterial> NewRenderingPipeline = Ext_DirectXMaterial::CreateMaterial("Basic");
NewRenderingPipeline->SetVertexShader("Basic_VS");
NewRenderingPipeline->SetPixelShader("Basic_PS");
NewRenderingPipeline->SetBlendState("BaseBlend"); // 아직 없음
NewRenderingPipeline->SetDepthState("EngineDepth");
NewRenderingPipeline->SetRasterizer("EngineRasterizer"); // 아직 없읍
}
[Z-Buffer, Z-Test, Z-Culling]
Z-Buffer(Depth Buffer)는 3D 공간 상에서 픽셀이 카메라로부터 얼마나 떨어져 있는지(깊이 정보)를 저장하는 버퍼입니다. 각 픽셀이 가진 깊이값(Z값)은 0.0f ~ 1.0f의 범위를 가집니다. 이 값은 NDC.z값(w나누기 한 값)에 뷰포트 행렬을 적용한 값(Screen Space)입니다.
복습
이 Screen Space 좌표값으로 변환된 z값을 가지고 Z-Test(Depth Test)를 실시하게 되는데, Z-Test는 렌더링 시 현재 출력하려는 픽셀이 기존에 출력된 픽셀보다 카메라 기준으로 더 앞쪽에 있는지를 판별하는 과정입니다. 이 판별이 끝나면 겹쳐진 오브젝트 중 앞에 있는 것만 그려지는 효과가 발생하는데, 이걸 Z-Culling이라고 합니다.
[Z-Sorting]
여기까지 했으면 정상적으로 Z-Culling이 발생해야하겠지만, 아쉽게도 현재의 프레임워크 설계에서는 다음의 문제가 발생하게 됩니다.
영상을 보시면 회전하고 있는 Object(B)는 가운데 있는 Object(A)의 Y축을 기준으로 주변을 회전하고 있습니다. 이러면 뒤로 가는 순간이 분명 있을 텐데, 뒤로 갔음에도 불구하고 A보다 앞에 그려집니다.
이런 경우가 발생하는 이유는 현재 DrawIndexed()를 Object를 순회하면서 실시하고 있기 때문입니다. 아무리 B가 A보다 뒤에 있다고 해도 A를 먼저 그리고 다음에 B를 그려버리면 당연히 B가 A보다 앞에 그려질 것입니다. 그 현상이 위의 영상에 나타나고 있습니다.
위 문제를 해결하기 위해 임의로 Z-Sorting을 실시했습니다. 이러면 MeshComponent의 Z값 기준으로 맨 뒤에 있는 결과물부터 순서대로 그려줄 것이기 때문에 예상했던 대로 그려지게 됩니다.
[Stencil]
해당 프레임워크에서는 사용할 예정이 딱히 없기 때문에, 개념만 짚고 넘어가겠습니다.
DSV에서 각 픽셀의 Depth와 Stencil 값 표현 자료형을 DXGI_FORMAT_D24_UNORM_S8_UINT로 정의했기 때문에, 앞의 3byte를 제외한 나머지 1byte는 Stencil값을 표현하기 위해 사용됩니다(0~255). 이걸 활용하면 픽셀의 스텐실 값을 비교하여, 조건 만족 시에만 렌더 타겟 뷰에 그리도록 설정할 수 있습니다(그림자 영역, 외곽선, 복잡한 후처리 효과 등).
사용 예시는 다음과 같습니다. State Desc를 만들 때, 아래의 인자들을 활용해주면 됩니다.
이런 식으로 활용해서, 특정 부분을 1로 마킹한 다음 그것만 따로 그리는 방식이 가능합니다.
출처 : https://heinleinsgame.tistory.com/25
[OutputMerger 단계]
OutputMerger는 렌더링 파이프라인의 가장 마지막 단계입니다. 여기서 모든 픽셀 처리 결과가 RenderTarget에 실제로 기록될지 말지를 결정하게 되는데, 해당 단계에서 Z-Test, Stencil Test, Blending이 수행됩니다.
1. Z-Test
: 픽셀 Z값과 Z-Buffer를 비교하여 멀면 버리고, 가깝거나 같으면 통과시킴
2. Stencil Test
: 픽셀 스텐실값 비교 후 실패시 버리는 과정 실시, 복잡한 마스킹 등
3. Blending
: 기존 픽셀 색상과 새 픽셀 색상을 혼합, 알파 블랜딩, 가산 혼합 등 다양하게 활용
4. Render Target Output
: 위 세 조건을 통과한 픽셀만 렌더 타겟 뷰에 기록
// 1. 깊이/스텐실 상태 설정
Context->OMSetDepthStencilState(DepthStencilState, StencilRef);
// 2. 블렌드 상태 설정
Context->OMSetBlendState(BlendState, BlendFactor, SampleMask);
// 3. 렌더 타겟 및 깊이 버퍼 바인딩
Context->OMSetRenderTargets(NumViews, RenderTargetViewArray, DepthStencilView);
먼저 두 벡터의 요소들에 대한 GCD를 구한다. 그리고 GCD가 1일 경우를 제외하고, 다른 벡터에서 구한 GCD를 요소에 대해 모두 나눠보는 시도를 진행한다. 이때 한 번도 나눠지지 않는다면 true가 되면서 조건을 만족하게 된다. 하지만 다른 벡터도 조건식을 통과하는지 검사하기 때문에, 두 벡터 모두 만족하는 수가 나올 수 있다. 이때는 가장 큰 값을 판별하기 위해 max를 활용하여 값 리턴하면 된다.
#include <string>
#include <vector>
using namespace std;
int GCD(int a, int b)
{
while (b != 0)
{
int temp = a % b;
a = b;
b = temp;
}
return a;
}
int GetArrayGCD(const vector<int>& arr)
{
int Result = arr[0];
for (int i = 1; i < arr.size(); i++)
{
Result = GCD(Result, arr[i]);
}
return Result;
}
bool Try(const vector<int>& _Arr, int _GCD)
{
if (1 == _GCD) return false;
for (int val : _Arr)
{
if (val % _GCD == 0)
{
return false;
}
}
return true;
}
// 다음 두 조건 중 하나를 만족하는 가장 큰 양의 정수 a의 값
// 1. arrayA는 모두 나눌 수 있고, arrayB는 모두 나눌 수 있는 수
// 2. arrayA는 모두 나눌 수 없고, arrayB는 모두 나눌 수 있는 수
int solution(vector<int> arrayA, vector<int> arrayB)
{
int answer = 0;
int AGcd = GetArrayGCD(arrayA);
int BGcd = GetArrayGCD(arrayB);
// 조건 1: A를 모두 나눌 수 있고, B를 하나도 나눌 수 없어야 함
if (Try(arrayB, AGcd))
{
answer = max(answer, AGcd);
}
// 조건 2: B를 모두 나눌 수 있고, A를 하나도 나눌 수 없어야 함
if (Try(arrayA, BGcd))
{
answer = max(answer, BGcd);
}
return answer;
}
1. 어느 시간대 이용자가 n x m명 이상, (n + 1) x m명 미만이라면 최소 n대의 증설 서버가 필요
: m이 3이라면, 0 ~ 2명까지는 증설이 필요없고, 3명부터는 증설을 해야한다. 3 ~ 5명은 1대, 6 ~ 8명은 2대, 9 ~ 11명은 3대 이런 식으로 서버가 필요한 것이다.
2. k에 따라 서버 운영 수명이 정해져 있음
: k는 증설 시점으로 부터 k시간 후 회수하는 인자이기 때문에, 서버의 수명이다
이 두개만 기억하고 시뮬레이션을 돌리면 된다. 루프 한번은 시간대이다(0~1시간대, 1~2시간대, ..., 23~24시간대). 증설된 서버는 list로 관리하면서, 서버가 증설중인 상태라면 검사를 통해 수명 관리를 실시했다. Time이 k가 되면 list에서 제거하는 것이다.
#include <string>
#include <vector>
#include <list>
using namespace std;
struct Server
{
int Time = 0;
bool TimeCheck(int _Limit)
{
++Time;
if (Time == _Limit)
{
return true;
}
return false;
}
};
// m명 늘어날 때마다 서버 1대가 추가
// 어느 시간대의 이용자가 n x m명 이상 (n + 1) x m명 미만 = 최소 n대의 증설된 서버가 운영 중
// 서버는 k시간 동안 운영하고 그 이후에는 반납, k = 5 일 때 10시에 증설한 서버는 10 ~ 15시에만 운영
// 하루 동안 모든 게임 이용자가 게임을 하기 위해 서버를 최소 몇 번 증설해야 하는지 알고 싶습니다. 같은 시간대에 서버를 x대 증설했다면 해당 시간대의 증설 횟수는 x회
int solution(vector<int> players, int m, int k)
{
int answer = 0;
std::list<Server> ServerList;
for (int i = 0; i < players.size(); i++)
{
int CurPlayer = players[i];
int NeedCount = CurPlayer / m;
if (ServerList.empty())
{
for (int i = 0; i < NeedCount; i++)
{
ServerList.push_back({ 0 });
++answer;
}
}
if (!ServerList.empty())
{
int CurServerCount = ServerList.size();
if (NeedCount > CurServerCount)
{
for (int i = 0; i < NeedCount - CurServerCount; i++)
{
ServerList.push_back({ 0 });
++answer;
}
}
for (auto It = ServerList.begin(); It != ServerList.end();)
{
if (It->TimeCheck(k))
{
It = ServerList.erase(It); // erase는 삭제 후 다음 iterator를 반환
}
else
{
++It;
}
}
}
}
return answer;
}
3. 이후 퇴실 시간(청소 시간 10분 포함)을 기준으로 저장할 RoomEndTimes 우선순위 큐를 하나 더 만든다.
이제 시뮬레이션 과정이다.
1. DataQueue에서 가장 시작 시간이 빠른 예약을 꺼낸다.
2. RoomEndTimes의 top 값을 확인한다.
3. 만약 현재 손님의 시작 시간이 top값(가장 빨리 끝나는 방 시간)보다 크거나 같다면, 해당 방은 예약이 가능한 구조이기 때문에 pop을 실시한다.
4. 그 외의 경우에는 새로운 방이 필요한 상황이기 때문에 그냥 진행
5. 현재 손님의 End 시간을 RoomEndTimes에 push 한다.
위의 과정을 모두 진행하면, RoomEndTimes에 남아 있는 방의 개수가 필요한 최소 객실 수이다. 이 방법은 매번 끝나는 시간 중 가장 빠른 것을 기준으로 비교하므로, 불필요한 방 및 예약 중복 탐색을 피할 수 있다.
#include <string>
#include <vector>
#include <queue>
using namespace std;
struct Data
{
Data(int _Start, int _End)
: Start(_Start), End(_End) {}
int Start = 0;
int End = 0;
};
struct Compare
{
bool operator()(const Data& A, const Data& B) const
{
return A.Start > B.Start; // Start가 작은 게 먼저 나오는 min heap
}
};
int ClockToMinute(string _Str)
{
string Hour = _Str.substr(0, 2);
string Min = _Str.substr(3, 2);
int H = atoi(Hour.data());
int M = atoi(Min.data());
return M + (H * 60);
}
// 호텔을 운영 중인 코니는 최소한의 객실만을 사용하여 예약 손님들을 받으려고 합니다
// 한 번 사용한 객실은 퇴실 시간을 기준으로 10분간 청소를 하고 다음 손님들이 사용
int solution(vector<vector<string>> book_time)
{
int answer = 0;
// 끝나는 시간 기준 넣을 수 있는 값이 있는지 확인
// 넣을 수 있는 값중 끝나는 시간이 제일 넣어줌
priority_queue<Data, vector<Data>, Compare> DataQueue;
for (int i = 0; i < book_time.size(); i++)
{
int Start = ClockToMinute(book_time[i][0]);
int End = 10 + ClockToMinute(book_time[i][1]);
DataQueue.push(Data(Start, End));
}
priority_queue<int, vector<int>, greater<int>> RoomEndTimes;
// 3. 예약 순회하면서 방 할당
while (!DataQueue.empty())
{
Data Cur = DataQueue.top();
DataQueue.pop();
// 가장 빨리 비는 방의 시간이 현재 시작 시간보다 작거나 같다면 → 재사용 가능
if (!RoomEndTimes.empty() && RoomEndTimes.top() <= Cur.Start)
{
RoomEndTimes.pop(); // 기존 방 재사용
}
RoomEndTimes.push(Cur.End); // 새로운 방 또는 재사용된 방의 끝 시간 기록
}
return answer = RoomEndTimes.size();
}
간선과 노드를 정리하고, 정리된 컨테이너를 활용하여 BFS를 실시한다. 노드를 들리면 Count를 하나 증가시키고 해당 노드 방문 여부인 Visited를 true로 마킹한다. 이 과정을 반복하면 연결된 요소에 대해서는 탐색을 진행할 수 있다. 이후 탐색을 모두 완료한 갯수에서 전체 노드 갯수를 빼주면 나머지는 방문하지 않아도 네트워크 구성망이 몇개인지 바로 알 수 있다.
#include <string>
#include <vector>
#include <algorithm>
#include <queue>
using namespace std;
int BFS(int _Start, const vector<vector<int>>& _Graph, vector<bool>& _Visited)
{
queue<int> Q;
Q.push(_Start);
_Visited[_Start] = true;
int Count = 1;
while (!Q.empty())
{
int Cur = Q.front();
Q.pop();
for (int Next : _Graph[Cur])
{
if (!_Visited[Next])
{
_Visited[Next] = true;
Q.push(Next);
++Count;
}
}
}
return Count;
}
// n개의 송전탑이 전선을 통해 하나의 트리 형태로 연결
// 이 전선들 중 하나를 끊어서 현재의 전력망 네트워크를 2개로 분할
// 두 전력망이 갖게 되는 송전탑의 개수를 최대한 비슷하게
int solution(int n, vector<vector<int>> wires)
{
int answer = n;
for (int i = 0; i < wires.size(); ++i)
{
vector<vector<int>> Graph(n + 1); // Graph 매 회 생성
for (int j = 0; j < wires.size(); ++j)
{
if (i == j) continue; // i 번째 간선 제거
int From = wires[j][0];
int To = wires[j][1];
Graph[From].push_back(To);
Graph[To].push_back(From);
}
vector<bool> Visited(n + 1, false);
// 하나의 컴포넌트에서 BFS 시작 (보통 1번 노드부터)
int A = BFS(1, Graph, Visited);
int B = n - A;
answer = min(answer, abs(A - B));
}
return answer;
}
Actor는 생성하면서 +Z방향으로 이동시켰습니다. 이러면 카메라에서 100.0f만큼 앞으로 떨어진 부분에 생성될 것입니다.
[Rendering Pipeline]
내용이 길어, 단계별로 간단하게 정리한 것 먼저 알려드리겠습니다.
ClearRenderTargetView() // 렌더타겟뷰 클리어
ClearDepthStencilView() // 뎁스/스텐실뷰 클리어
OMSetRenderTargets() // 렌더타겟뷰 바인딩
RSSetViewports() // 뷰포트 바인딩
LookToLH() // 뷰 행렬 생성
PerspectiveForLH() // 프로젝션 행렬 생성
SetViewProjectionMatrixToMeshComponent() // 뷰, 프로젝션 행렬 적용
IASetInputLayout() // 정점 입력 레이아웃 설정(정점 버퍼 데이터 해석 설명서 전달)
IASetVertexBuffers() // 정점 버퍼 종류 설정
IASetPrimitiveTopology() // 도형 종류 설정
IASetIndexBuffer() // 인덱스 버퍼 설정
VSSetShader() // 버텍스 셰이더 설정
PSSetShader() // 픽셀 셰이더 설정
Map(), Unmap() // 프레임마다 GPU에게 버퍼 변경내용 전달
VSSetConstantBuffers() // 정점 셰이더 바인딩
PSSetConstantBuffers() // 픽셀 셰이더 바인딩
DrawIndexed() // 정점 출력 명령
Present() // 렌더타겟뷰에 그려진 결과 백버퍼에 출력
위의 단계를 모두 진행해야합니다. 하나씩 함수 호출 부분을 살펴보겠습니다. 먼저 백 버퍼 클리어를 한 뒤 세팅을 진행합니다.
// Textures에 저장된 렌더 타겟 뷰들을 모두 클리어
void Ext_DirectXRenderTarget::RenderTargetViewsClear()
{
for (size_t i = 0; i < Textures.size(); i++)
{
for (size_t j = 0; j < Textures[i]->GetRTVSize(); j++)
{
COMPTR<ID3D11RenderTargetView> RTV = Textures[i]->GetRTV(j); // 하나의 텍스쳐가 여러 개를 가지고 있을 수 있음
if (nullptr == RTV)
{
MsgAssert("존재하지 않는 랜더타겟뷰를 클리어할 수는 없음");
return;
}
Ext_DirectXDevice::GetContext()->ClearRenderTargetView(RTV.Get(), Colors[i].Arr1D); // 기본 컬러(파란색)으로 클리어
// 1. 클리어 대상
// 2. 무슨 색으로 클리어 할것인가
}
}
}
// 렌더링 시작 시 깊이 및 스텐실 버퍼를 초기화하여, 이전 프레임에 남아있는 데이터 제거
void Ext_DirectXRenderTarget::DepthStencilViewClear()
{
COMPTR<ID3D11DepthStencilView> DSV = DepthTexture->GetDSV(); // 깊이/스텐실 텍스쳐를 담고 있는 객체
if (nullptr == DSV)
{
MsgAssert("존재하지 않는 뎁스스텐실뷰를 클리어할 수는 없음");
return;
}
Ext_DirectXDevice::GetContext()->ClearDepthStencilView(DSV.Get(), D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0);
// 1. 클리어 대상
// 2. 어떻게 클리어 할것인가
// 3. 깊이 클리어값(가장 멀리)
// 4. 스텐실 클리어값(일반적인 0을 넣음)
}
// 렌더 타겟 바인딩 담당, Draw(), Clear(), Shader Binding 등이 올바른 렌더 타겟에 수행될 수 있도록 설정
void Ext_DirectXRenderTarget::RenderTargetSetting()
{
COMPTR<ID3D11RenderTargetView> RTV = Textures[0]->GetRTV(0); // 첫 번째 RTV(RenderTargetView) 를 가져옴(백 버퍼임)
if (nullptr == RTV)
{
MsgAssert("랜더타겟 뷰가 존재하지 않아서 클리어가 불가능합니다.");
}
COMPTR<ID3D11DepthStencilView> DSV = DepthTexture->GetDSV(); // 깊이 텍스처에서 DSV(DepthStencilView) 획득
//if (false == DepthSetting)
//{
// DSV = nullptr;
//}
if (nullptr == DSV)
{
MsgAssert("뎁스스텐실뷰 왓");
}
Ext_DirectXDevice::GetContext()->OMSetRenderTargets(static_cast<UINT>(RTVs.size()), RTV.GetAddressOf(), DSV.Get()); // Output-Merger 스테이지에 렌더 타겟 + 뎁스 설정
// 1. 바인딩할 렌더타겟뷰 갯수
// 2. RTV의 시작 주소
// 3. 깊이/스텐실 뷰 포인터(딱히 없으면nullptr 가능)
Ext_DirectXDevice::GetContext()->RSSetViewports(static_cast<UINT>(ViewPorts.size()), &ViewPorts[0]); // Rasterizer 스테이지에 현재 프레임에서 사용할 ViewPort 영역 설정, 이게 있어야 NDC > 픽셀 공간 변환이 올바르게 수행됨
}
// 카메라의 MeshComponents들에 대한 업데이트 및 렌더링 파이프라인 리소스 정렬
void Ext_Camera::Rendering(float _Deltatime)
{
// MeshComponents 렌더링 업데이트 시작
for (auto& [Key, MeshComponentList] : MeshComponents)
{
// [!] 필요하면 ZSort 실시(나중에)
for (const std::shared_ptr<Ext_MeshComponent>& CurMeshComponent : MeshComponentList)
{
if (!CurMeshComponent->GetIsUpdate()) continue;
else
{
CurMeshComponent->Rendering(_Deltatime, GetViewMatrix(), GetProjectionMatrix()); // [3] 현재 MeshComponent에게 카메라의 View, Projection 곱해주기
// [!] 필요하면 픽셀 셰이더에서 활용할 Value들 업데이트
}
}
}
//....
}
이제 메시 컴포넌트에 달려있는 MeshComponentUnits의 버퍼값과 셰이더정보를 활용하여 렌더링 파이프라인을 진행합니다.
// Mesh, Material의 RenderingPipeline Setting
void Ext_MeshComponentUnit::RenderUnitSetting()
{
if (nullptr == Mesh)
{
MsgAssert("매쉬가 존재하지 않는 유니트 입니다");
}
if (nullptr == Material)
{
MsgAssert("파이프라인이 존재하지 않는 유니트 입니다");
}
InputLayout->InputLayoutSetting(); // InputLayout으로 IASetInputLayout 호출
Mesh->MeshSetting(); // InputAssembler 1단계, InputAssembler 2단계 호출 : IASetVertexBuffers, IASetPrimitiveTopology, IASetIndexBuffer
Material->MaterialSetting(); // VertexShader, PixelShader 호출(렌더링 파이프라인 단계 실행) : VSSetShader, PSSetShader
BufferSetter.BufferSetting(); // Map -> memcpy -> UnMap, VSSetConstantBuffers, PSSetConstantBuffers 실시
}
순서대로 확인해보겠습니다.
// 입력 조립기 단계 중 정점 입력 레이아웃을 설정하는 함수, GPU가 정점 버퍼의 데이터를 올바르게 해석하기 위해 필수
void Ext_DirectXInputLayout::InputLayoutSetting()
{
if (nullptr == InputLayout)
{
MsgAssert("생성되지 않은 인풋레이아웃을 세팅하려고 했습니다.");
}
Ext_DirectXDevice::GetContext()->IASetInputLayout(InputLayout);
// POSITION, COLOR, TEXTCOOR, NORMAL 등 정보를 GPU에 알려줘서 셰이더와 정점 데이터가 정확하게 매칭되도록 함
}
// 정점 버퍼 종류 설정
void Ext_DirectXVertexBuffer::VertexBufferSetting()
{
if (nullptr == VertexBuffer)
{
MsgAssert("버텍스 버퍼가 없어 세팅 불가");
return;
}
Ext_DirectXDevice::GetContext()->IASetVertexBuffers(0, 1, VertexBuffer.GetAddressOf(), &VertexSize, &Offset);
// 1. 가본 정점 슬롯
// 2. 버퍼 하나만 사용
// 3. 버텍스 버퍼 주소
// 4. 정점 하나당 크기(Stride)
// 5. 버퍼 시작 오프셋(일반적으로 0)
}
// 정점 버퍼 / 도형 종류 설정
void Ext_DirectXMesh::InputAssembler1()
{
if (nullptr == VertexBufferPtr)
{
MsgAssert("버텍스 버퍼가 존재하지 않아서 인풋어셈블러1 과정을 실행할 수 없습니다.");
return;
}
Ext_DirectXDevice::GetContext()->IASetPrimitiveTopology(Topology); // 도형 종류 설정
// GPU에 삼각형 리스트, 라인 스트립, 포인트 리스트 등을 설정
// 정점들을 삼각형으로 해석하도록 하기 위해 D3D10_PRIMITIVE_TOPOLOGY_TRIANGLELIST 전달
}
// 인덱스 버퍼 종류 설정
void Ext_DirectXIndexBuffer::IndexBufferSetting()
{
if (nullptr == IndexBuffer)
{
MsgAssert("ID3DBuffer가 만들어지지 않은 버텍스 버퍼 입니다.");
return;
}
Ext_DirectXDevice::GetContext()->IASetIndexBuffer(IndexBuffer.Get(), Format, Offset);
// 1. 인덱스 버퍼
// 2. 인덱스 데이터 타입, DXGI_FORMAT_R32_UINT 전달
// 3. Offset 전달, 보통 0부터 시작
}
// VSSetShader 호출로 정점 셰이더 세팅
void Ext_DirectXVertexShader::VertexShaderSetting()
{
if (nullptr == VertexShader.Get())
{
MsgAssert("정점 셰이더가 존재하지 않아 세팅에 실패");
}
Ext_DirectXDevice::GetContext()->VSSetShader(VertexShader.Get(), nullptr, 0);
// 1. 바인디할 셰이더 객체
// 2. 셰이더에 사용할 클래스 배열 전달(보통 nullptr)
// 3. 클래스 개수(보통 0)
}
void Ext_DirectXMaterial::PixelShaderSetting()
{
if (nullptr == PixelShader)
{
MsgAssert("픽셀 쉐이더가 존재하지 않아서 픽셀 쉐이더 과정을 실행할 수 없습니다.");
return;
}
PixelShader->PixelShaderSetting();
}
// Map(), Unmap() 실시
void Ext_DirectXConstantBuffer::ChangeData(const void* _Data, UINT _Size)
{
// 머티리얼들은 상수버퍼나 이런걸 통일해 놓은 것이다.
if (nullptr == _Data)
{
std::string CurName = Name;
MsgAssert(CurName + "에 nullptr인 데이터를 세팅하려고 했습니다.");
return;
}
if (ConstantBufferInfo.ByteWidth != _Size)
{
std::string CurName = Name;
MsgAssert(CurName + "크기가 다른 데이터가 들어왔습니다.");
return;
}
D3D11_MAPPED_SUBRESOURCE SettingResources = { 0, };
// 매 프레임마다 카메라 위치, 뷰/프로젝션 행렬, 시간, 조명 데이터 등을 셰이더에 넘겨야 할 때(버퍼의 내용을 바꾸는 경우)를 위해 D3D11_CPU_ACCESS_WRITE 설정, 이러면 반드시 D3D11_USAGE_DYNAMIC 이어야함
// 설정이 없으면 Map이 불가능 → UpdateSubresource()를 사용해야 함 (비효율적), GPU에서만 읽고 CPU가 직접 쓰지 못해 프레임별 갱신이 불가능
Ext_DirectXDevice::GetContext()->Map(ConstantBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &SettingResources); // GPU 버퍼를 CPU에서 쓸 수 있도록 잠금
// 1. 실제 GPU 상수 버퍼 객체
// 2. 서브 리소스 index(항상 0)
// 3. 기존 내용을 폐기하고 새로 쓰기(가장 빠름)
// 4. ?
// 5. 매핑된 데이터 포인터를 받을 구조체
if (SettingResources.pData == nullptr)
{
std::string CurName = Name;
MsgAssert(CurName + " 그래픽카드에게 메모리 접근을 허가받지 못했습니다.");
return;
}
memcpy_s(SettingResources.pData, ConstantBufferInfo.ByteWidth, _Data, ConstantBufferInfo.ByteWidth); // CPU측 메모리 _Data의 내용을 GPU측 상수 버퍼로 복사
Ext_DirectXDevice::GetContext()->Unmap(ConstantBuffer, 0); // 쓰기 완료(잠금 해제), GPU에게 알려줌, 이거 안하면 렌더링 중 GPU가 해당 버퍼를 읽으려할 때 충돌 발생 가능
}
// 정점 셰이더 바인딩
void Ext_DirectXConstantBuffer::VSSetting(UINT _Slot)
{
Ext_DirectXDevice::GetContext()->VSSetConstantBuffers(_Slot, 1, &ConstantBuffer);
// 1. 셰이더 내 CBuffer가 바인딩된 슬롯 인덱스(cbuffer TransformData : register(b0) 한개만 있으니, 0 전달됨)
// 2. 설정할 버퍼 개수(하나만 바인딩)
// 3. ID3D11Buffer* 주소 전달
}
// PSSetConstantBuffers() 호출
void Ext_DirectXConstantBuffer::PSSetting(UINT _Slot)
{
Ext_DirectXDevice::GetContext()->PSSetConstantBuffers(_Slot, 1, &ConstantBuffer);
// 아직 뭐 없음
}
// 정점 정보들과 셰이더를 통해 메시 Draw 실시(DrawIndexed Call)
void Ext_MeshComponentUnit::RenderUnitDraw()
{
UINT IndexCount = Mesh->GetIndexBuffer()->GetVertexCount();
Ext_DirectXDevice::GetContext()->DrawIndexed(IndexCount, 0, 0);
}
// 정점 정보들과 셰이더를 통해 메시 Draw 실시(DrawIndexed Call)
void Ext_MeshComponentUnit::RenderUnitDraw()
{
UINT IndexCount = Mesh->GetIndexBuffer()->GetVertexCount();
Ext_DirectXDevice::GetContext()->DrawIndexed(IndexCount, 0, 0); // 파이프라인 설정 완료 후, GPU에게 그리라고 명령
// 1. 그릴 인덱스 갯수(Mesh가 가진 전체 인덱스 갯수)
// 2. 인덱스 버퍼 내에서 시작할 위치(보통 0부터 시작)
// 3. 정점 버퍼 내 정점 인덱스의 시작값 오프셋(주로 0)
}
위의 일련의 렌더링 파이프라인 단계를 거쳐, DrawIndexed를 실시하면 백버퍼에 출력 결과가 그려집니다. 마지막으로 Present를 실시해주면 됩니다.
// MeshComponent Render 업데이트 후 백버퍼에 Present 호출
void Ext_DirectXDevice::RenderEnd()
{
HRESULT Result = SwapChain->Present(0, 0);
if (Result == DXGI_ERROR_DEVICE_REMOVED || Result == DXGI_ERROR_DEVICE_RESET)
{
// 디바이스 다시만들기
MsgAssert("랜더타겟 생성에 실패했습니다.");
return;
}
}
[결과]
화면을 자세히 보면 가운데 초록색 점이 하나 보입니다. 이건 Object의 크기가 너무 작아서 그런 것인데, 크기를 키워도 되고 거리를 가깝게 해도 됩니다. 거리를 가깝게 해보겠습니다. 지금은 카메라의 월드 위치가 { 0.0f, 0.0f, 100.0f }에 위치하고 있는데{ 0.0f, 0.0f, 1.0f }로 변경해보겠습니다.
렌더링 파이프라인 설정값을 렌더링될 Object마다 원하는 세팅으로 설정해주면 좋을 것입니다. 이렇게 하는게 가능한 이유가 해당 프레임워크에서는 리소스를 만들때마다 리소스 매니저를 통해 값들을 이름으로 저장하고 있기 때문입니다.
이걸 십분 활용해서 Ext_DirectXMesh 클래스와 Ext_DirectXMaterial 클래스를 만들었습니다. Mesh 클래스는 InputAssembler 단계에 대한 정보를 담은 클래스, Material 클래스는 그 외의 모든 단계에 대한 정보를 담은 클래스입니다.
현재의 프레임워크 구조에서, Actor 내부에서 Component를 Create 하는 함수를 호출하면 Component가 생성됩니다. 여기서 MeshComponent의 경우 CreateMeshComponentUnit() 함수를 호출하면서 Mesh와 Material을 이름으로 하여 세팅하도록 설계해뒀습니다. 이러면 이름으로 값을 찾아서 내부에서 세팅해줍니다. 물론 먼저 만들어둔 값이 있어야 할 것입니다.
[Ext_DirectXMesh]
먼저 Mesh 클래스입니다.
////////////////////////// Ext_DirectXMesh.h
#pragma once
#include "Ext_ResourceManager.h"
#include "Ext_DirectXVertexBuffer.h"
#include "Ext_DirectXIndexBuffer.h"
// 만들어진 버텍스들의 정보를 저장하기 위한 클래스
class Ext_DirectXMesh : public Ext_ResourceManager<Ext_DirectXMesh>
{
friend class Ext_MeshComponentUnit;
public:
// constrcuter destructer
Ext_DirectXMesh() {}
~Ext_DirectXMesh() {}
// delete Function
Ext_DirectXMesh(const Ext_DirectXMesh& _Other) = delete;
Ext_DirectXMesh(Ext_DirectXMesh&& _Other) noexcept = delete;
Ext_DirectXMesh& operator=(const Ext_DirectXMesh& _Other) = delete;
Ext_DirectXMesh& operator=(Ext_DirectXMesh&& _Other) noexcept = delete;
// 버텍스 버퍼와 인덱스 버퍼 정보 입력 및 메시 생성
static std::shared_ptr<Ext_DirectXMesh> CreateMesh(std::string_view _Name)
{
return CreateMesh(_Name, _Name, _Name);
}
// 버텍스 버퍼와 인덱스 버퍼 정보 입력 및 생성
static std::shared_ptr<Ext_DirectXMesh> CreateMesh(std::string_view _Name, std::string_view _VBName, std::string_view _IBName)
{
std::shared_ptr<Ext_DirectXMesh> NewMesh = Ext_ResourceManager::CreateNameResource(_Name);
NewMesh->VertexBufferPtr = Ext_DirectXVertexBuffer::Find(_VBName);
NewMesh->IndexBufferPtr = Ext_DirectXIndexBuffer::Find(_IBName);
if ((nullptr == NewMesh->VertexBufferPtr) || (nullptr == NewMesh->IndexBufferPtr))
{
MsgAssert("메시 생성 실패");
}
return NewMesh;
}
// Getter
std::shared_ptr<class Ext_DirectXVertexBuffer> GetVertexBuffer() { return VertexBufferPtr; }
std::shared_ptr<class Ext_DirectXIndexBuffer> GetIndexBuffer() { return IndexBufferPtr; }
protected:
private:
D3D11_PRIMITIVE_TOPOLOGY Topology = D3D11_PRIMITIVE_TOPOLOGY::D3D10_PRIMITIVE_TOPOLOGY_TRIANGLELIST;
std::shared_ptr<class Ext_DirectXVertexBuffer> VertexBufferPtr; // 상수버퍼 데이터 저장용
std::shared_ptr<class Ext_DirectXIndexBuffer> IndexBufferPtr; // 인덱스버퍼 데이터 저장용
void MeshSetting(); // InputAssembler1(), InputAssembler2() 호출
void InputAssembler1(); // 인풋어셈블러 1단계 실시, IASetVertexBuffers(), IASetPrimitiveTopology() 실시
void InputAssembler2(); // 인풋어셈블러 2단계 실시, IndexBufferSetting() 호출
};
////////////////////////// Ext_DirectXMesh.cpp
#include "PrecompileHeader.h"
#include "Ext_DirectXMesh.h"
#include "Ext_DirectXVertexBuffer.h"
#include "Ext_DirectXIndexBuffer.h"
#include "Ext_DirectXDevice.h"
void Ext_DirectXMesh::MeshSetting()
{
InputAssembler1();
InputAssembler2();
}
void Ext_DirectXMesh::InputAssembler1()
{
if (nullptr == VertexBufferPtr)
{
MsgAssert("버텍스 버퍼가 존재하지 않아서 인풋어셈블러1 과정을 실행할 수 없습니다.");
return;
}
VertexBufferPtr->VertexBufferSetting();
Ext_DirectXDevice::GetContext()->IASetPrimitiveTopology(Topology);
}
void Ext_DirectXMesh::InputAssembler2()
{
// 그리는 순서에 대한 데이터를 넣어준다 // 012023
if (nullptr == IndexBufferPtr)
{
MsgAssert("인덱스 버퍼가 존재하지 않아서 인풋 어셈블러2 과정을 실행할 수 없습니다.");
return;
}
IndexBufferPtr->IndexBufferSetting();
}
Mesh 클래스는 Ext_DirectXVertexBuffer, Ext_DirectXIndexBuffer 정보를 가지고 있는 클래스입니다. 아래와 같이 사용해봅시다.
CreateVertexBuffer(), CreateIndexBuffer()을 실시할 때 Triangle, Rect라는 이름으로 저장해줬습니다. 이러면 CreateMesh()를 실시할 때 동일한 이름을 넣어줘서 내부에서 Triangle, Rect라는 이름의 Vertex Buffer, Index Buffer를 Find()한 뒤 저장해두는 것입니다. 이러면 Mesh 클래스 하나로 두 가지 클래스를 바로 활용할 수 있게 됩니다.
머티리얼 클래스는 InputAssembler 이외의 단계를 진행하기 위한 정보들을 담고, 렌더링 파이프라인 세팅 순간에 그 정보들을 가져와 Setting을 실시해주는 클래스입니다.
물론 각 정보들은 이미 만들어져 있어야하며, 지금은 Vertex Shader와 Pixel Shader만 만들어졌기 때문에 이 값들만 저장하고 있는 클래스가 될 것입니다. 나중에 단계들을 배우고 추가하게 되면 여기에 바로바로 추가해주면 됩니다. 우선은 아래와 같이 사용하면 됩니다.
// 일반(단일 메시)
{
std::shared_ptr<Ext_DirectXMaterial> NewRenderingPipeline = Ext_DirectXMaterial::CreateMaterial("Basic");
NewRenderingPipeline->SetVertexShader("Basic_VS");
NewRenderingPipeline->SetPixelShader("Basic_PS");
NewRenderingPipeline->SetBlendState("BaseBlend"); // 아직 없음
NewRenderingPipeline->SetDepthState("EngineDepth"); // 아직 없음
NewRenderingPipeline->SetRasterizer("EngineRasterizer"); // 아직 없읍
}
// 다양한 텍스쳐가 있는 메시
{
std::shared_ptr<Ext_DirectXMaterial> NewRenderingPipeline = Ext_DirectXMaterial::CreateMaterial("PBR");
NewRenderingPipeline->SetVertexShader("Basic_VS");
NewRenderingPipeline->SetPixelShader("PBR_PS");
NewRenderingPipeline->SetBlendState("BaseBlend"); // 아직 없음
NewRenderingPipeline->SetDepthState("EngineDepth"); // 아직 없음
NewRenderingPipeline->SetRasterizer("EngineRasterizer"); // 아직 없음
}
이러면 Basic, PBR이라는 이름으로 Material들이 값들을 하나로 묶어서 저장하기 때문에, 클래스 하나만 써서 바로 활용할 수 있게 됩니다.
[Set Unit]
이제 다시 MeshComponentUnitInitialize() 함수로 돌아가보겠습니다. 함수 내에서는 앞서 생성해둔 Mesh와 Material 정보를 가져와 자신에게 세팅해줍니다. 그리고 InputLayout을 Create해서 Mesh, Material, InputLayout 값을 지니고 있게 됩니다.
// 메시 컴포넌트 유닛 생성 시 호출, Mesh, Material, ConstantBuffer 세팅
void Ext_MeshComponentUnit::MeshComponentUnitInitialize(std::string_view _MeshName, std::string_view _MaterialName)
{
Mesh = Ext_DirectXMesh::Find(_MeshName); // 메시 설정
Material = Ext_DirectXMaterial::Find(_MaterialName); // 머티리얼 설정
if (nullptr == Mesh || nullptr == Material)
{
MsgAssert("존재하지 않는 메시나 머티리얼을 메시유닛에 넣을 수는 없습니다.")
}
InputLayout->CreateInputLayout(Mesh->GetVertexBuffer(), Material->GetVertexShader()); // InputLayout 설정
// 상수버퍼 세팅
// [1] 버텍스 셰이더 정보 가져오기
const Ext_DirectXBufferSetter& VertexShaderBuffers = Material->GetVertexShader()->GetBufferSetter();
BufferSetter.Copy(VertexShaderBuffers);
// [2] 픽셀 셰이더 정보 가져오기
const Ext_DirectXBufferSetter& PixelShaderBuffers = Material->GetPixelShader()->GetBufferSetter();
BufferSetter.Copy(PixelShaderBuffers);
// [3] 트랜스폼 상수버퍼 세팅하기
const TransformData& Data = *(OwnerMeshComponent.lock()->GetTransform()->GetTransformData().get());
BufferSetter.SetConstantBufferLink("TransformData", Data);
// [4] 카메라에 넣기
GetOwnerMeshComponent().lock()->GetOwnerCamera().lock()->PushMeshComponentUnit(GetSharedFromThis<Ext_MeshComponentUnit>(), RenderPath::Unknown);
}
해당 포스팅에서 삼각형, 사각형을 화면에 출력하기 위해 간단한 Vertex Shader, Pixel Shader를 작성하여 바로 컴파일 한 뒤 사용했습니다. 하지만 이후 진행될 단계들에서는 다양한 그래픽스 효과를 알아볼 것이고, 여기에는 조금 더 복잡한 기능을 갖거나 한 번에 많은 양의 셰이더를 사용하는 로직이 필요할 수 있습니다.
결국 이러면 셰이더의 양은 많아질 것이기 때문에, 이들을 저 포스팅을 진행했을 때와 같이 각각 컴파일하는 과정을 진행한다면 코드의 양이 늘어나고 복잡해질 것으로 예상되었습니다. 이에 따라 셰이더 컴파일의 구조를 변경할 겸, 셰이더와 상수버퍼를 알아본 뒤 넘어가고자 합니다.
[셰이더란]
셰이더(Shader)는 GPU에서 실행되는 작은 프로그램으로, DirectX에서는 HLSL(Hight Level Shader Language)을 사용하여 작성됩니다.
Vertex Shader
정점의 위치 변환
Pixel Shader
픽셀 단위 색상 계산
Geometry Shader
정점 간 도형 생성
Compute Shader
일반 계산용(병렬 연산)
Tessllation Shader
정점 세분화
다양한 종류가 있지만, 해당 프레임워크에서는 일단 Vertex Shader, Pixel Shader만 다루고 이후 시간적 여유가 생긴다면 Particle System을 만드는 과정을 진행함에 따라 다른 셰이더도 알아보도록 하겠습니다.
[상수 버퍼(Constant Buffer)]
셰이더를 알아보기 전에 먼저 Constant Buffer에 대해 알아보도록 하겠습니다. Constant Buffer도 Vertex Buffer, Index Buffer와 마찬가지로 버퍼입니다. 결국 셰이더(GPU)에서 활용할 수 있도록 렌더랑 파이프라인에 바인딩을 진행해줄 수 있어야 합니다. 일단 만드는 과정을 알아보겠습니다.
위의 함수는 이후 셰이더 리플렉션 과정에서 호출될 함수인데, 어쨋든 Constant Buffer도 만들어지려면 CreateBuffer를 호출해줘야 합니다. 하지만 여기서 다른 버퍼와 중요한 차이점이 하나 있는데, 바로 CPUAccessFlags가 D3D11_CPU_ACCESS_WRITE라는 점입니다.
이 부분이 중요한 이유는 이게 있어야 cbuffer로 등록한 값들에 대해 CPU가 접근해서 수정할 것임을 명시할 수 있기 때문입니다. Vertex Buffer와 Index Buffer는 한 번 정해지면 변경되지 않는 고정된 값으로 볼 수 있습니다. 하지만 Constant Buffer는 만들어진 후 매프레임마다 계속해서 변경됩니다. CPU가 계속 수정하고 GPU가 이걸 넘겨받아서 사용하는 것입니다.
이 과정을 위해 Map(), memcpy(), Unmap()을 활용합니다.
// Map(), Unmap() 실시
void Ext_DirectXConstantBuffer::ChangeData(const void* _Data, UINT _Size)
{
// 머티리얼들은 상수버퍼나 이런걸 통일해 놓은 것이다.
if (nullptr == _Data)
{
std::string CurName = Name;
MsgAssert(CurName + "에 nullptr인 데이터를 세팅하려고 했습니다.");
return;
}
if (ConstantBufferInfo.ByteWidth != _Size)
{
std::string CurName = Name;
MsgAssert(CurName + "크기가 다른 데이터가 들어왔습니다.");
return;
}
D3D11_MAPPED_SUBRESOURCE SettingResources = { 0, };
// 그래픽카드야 너한테 보낼께있어 잠깐 멈춰봐
// D3D11_MAP_WRITE_DISCARD 최대한 빠르게 처리하는
Ext_DirectXDevice::GetContext()->Map(ConstantBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &SettingResources);
if (SettingResources.pData == nullptr)
{
std::string CurName = Name;
MsgAssert(CurName + " 그래픽카드에게 메모리 접근을 허가받지 못했습니다.");
return;
}
memcpy_s(SettingResources.pData, ConstantBufferInfo.ByteWidth, _Data, ConstantBufferInfo.ByteWidth);
Ext_DirectXDevice::GetContext()->Unmap(ConstantBuffer, 0);
}
+) 위의 방식이 아니면 UpdateSubresource()를 활용해야하는데, 이건 매프레임별 갱신이 불가능한 방식이라 실시간 렌더링을 진행해야하는 해당 프레임워크에서 적합한 방식이 아니라 따로 설명하지는 않겠습니다.
GPU는 기본적으로 병렬처리가 기본이기 때문에 언제 무슨일이 벌어질지 알 수 없습니다. 그렇다면 어떻게 싱글 스레드 연산(기본적으로)을 실시하는 CPU와 연동이 가능할까에 대한 해답이 Map() 함수입니다. 해당 함수를 호출하면 GPU에게 해당 값에 대해 변경이 이루어질 것이니 잠시 기다려달라고 말하는 것과 다름 없습니다. 이 함수 호출과 함께 memcpy로 전달할 값을 덮어쓴 뒤 Unmap() 함수로 작업이 끝났음을 알려줌으로써 CPU와 GPU를 동기화 시키는 것입니다. 이 과정을 마친 다음에야 상수 버퍼를 렌더링 파이프라인에 바인딩 시키는 것입니다.
확인해보면 구조체로 보이는 것이 3개, 함수처럼 보이는 것이 한 개 있습니다. 하나씩 알아보도록 하겠습니다.
1. cbuffer
: 이게 위에서 말씀드린 Constant Buffer입니다. 여기서는 TransformData를 넘겨받는 것으로 되어있는데, 해당 프레임워크에서 이 값의 위치는 각 Component(정확히는 렌더링 컴포넌트)의 Trasnform 내부입니다. 렌더링 파이프라인 과정 진행 전 TransformData를 Updata하는 과정(이전에 말씀드렸던 것과 같이 카메라의 뷰, 프로젝션 행렬을 연산하는 과정)을 거치기 때문에 값이 변경될 것이고, Map, Unmap으로 변경된 값을 적용시킨 뒤 바인딩되어 여기로 전달됩니다.
그리고 일반적인 struct와는 다르게 register(b0)이라는 것이 달려있는데, 이것은 바인딩 슬롯입니다. 셰이더 프로그램에는 각자의 바인딩 슬롯이 있습니다. 종류는 크게 네 가지입니다.
[1] b 슬롯
: 상수버퍼, 슬롯 갯수는 14개(b0 ~ b13), 크기 제한 4096byte
[2] t 슬롯
: 텍스쳐(정확히는 셰이더 리소스 뷰(SRV) 바인딩 슬롯이긴 함), 슬롯 갯수는 128개(t0 ~ t127), 크기 제한 없음
[3] s 슬롯
: 샘플러, 슬롯 갯수는 16개(s0 ~ s15), 데이터 형태가 아니라 메모리 크기 개념 없음
[4] u 슬롯
: Unordered Access View(UAV) 바인딩 슬롯인데, 이건 Compute Shader 전용, 슬롯 갯수는 64개(u0 ~ u63), 크기 제한 없음
각 상수 버퍼들은 모두 각자의 바인딩 슬롯을 가지고 있어야 합니다. 예를 들어, 위에서 TransformData를 b0 슬롯에 바인딩한 상태인데, 다른 상수버퍼인 LightData가 바인딩되어야 한다고 가정해보겠습니다. 이러면 register(b0)에는 이미 TransformData가 바인딩되어 있기 때문에 register(b1)에 바인딩해줘야 합니다. 이런 식으로 상수 버퍼가 추가될 때마다 각자 고유한 슬롯에 바인딩해줄 수 있도록 합니다.
그렇다면 TransformData(b0), LightData(b1)이 바인딩 된 상태에서 텍스쳐(t) 상수 버퍼가 새로 바인딩 되어야한다면 어떻게 해야할까요? 이럴 경우에는 그냥 t0에 바인딩하면 됩니다. b랑 t는 서로 다른 슬롯이기 때문에 뒤에 숫자는 상관이 없습니다. 물론 또 다른 텍스쳐를 추가로 바인딩할 경우에는 t1에 바인딩하면 됩니다. 샘플러도 마찬가지입니다.
cbuffer에 대해서는 중요한 부분이 하나 더 있습니다. 바로 외부에서 전달하는 셰이더 내부에서 사용할 때의 크기가 완전히 동일해야 한다는 것입니다. 예를 들어, 외부에서 40byte를 전달해줬다면 내부에서도 40byte를 그대로 받아서 사용해야합니다. 물론 GPU는 크기만 맞다면 상관없이 그냥 써버리기 때문에 순서도 중요합니다.
이 부분에서 종종 실수할 수 있는 점이 하나 있는데, cbuffer는 내부가 16byte 단위로 정렬된다는 점입니다. 예를 들어 아래와 같이 데이터가 들어온다고 가정해보겠습니다.
이러면 총 크기는 64 + 12 + 4 = 80이 됩니다. 만약 외부에서 이를 인지하지 않고 그냥 보내버리면 분명 동일한 형태로 보냈는데 값이 안맞을 수도 있습니다. 이런 실수를 방지하기 위해 외부에서도 그냥 전달할 구조체에 대해서는 alignas(16)을 실시해서 정렬을 해줘야 합니다.
2. VSInput
: 이 값은 CPU에서 정의한 뒤 바인딩해준 Vertex Buffer의 시멘틱들입니다. 여기서는 상수 버퍼와 조금 다른점이, 바깥에서 전달한 데이터와 여기서 받는 데이터의 크기가 굳이 같지 않아도 된다는 점입니다. 예를 들어 외부에서 POSITION, COLOR, TEXCOORD, NORMAL을 전달했다고 가정해봅시다. 이러면 cbuffer는 모두 동일하게 선언해줘야겠지만 Vertex Buffer는 그냥 순서만 맞으면 됩니다. 위에서는 그냥 동일하게 모두 동일하게 선언했지만, 난 그냥 두개만 쓸건데? 하면 아래와 같이 순서만 맞춰주면 된다는 것입니다.
: 이 부분은 연산 결과에 대해 어떤 값을 내보낼지 정의하는 구조체입니다. 이렇게 내보낸 값은 Rasterizer에서 활용하게 됩니다. 여기서 한 가지 중요한 점은 SV 태그가 붙은 시멘틱이 하나 있어야한다는 점입니다. 여기서는 POSITION에 SV를 붙였는데, 이건 시스템 시멘틱이라는 의미로 이 정점이 최종적으로 화면의 어디에 그려질 것인가를 GPU에게 알려주는 특별한 위치 정보라는 의미를 담고 있습니다.
Vertex Shader의 SV_POSITION 출력은 Rasterizer가 픽셀 위치 계산에 활용하게 되고, Pixel Shader의 SV_POSITION에 전달되어 현재 픽셀이 그려질 정확한 화면 좌표가 어딘지를 알아낼 수 있게 됩니다. 이외의 나머지 값은 단순한 속성(attribute)으로, 사용자가 넣고싶으면 넣고, 빼고싶으면 뺄 수 있는 값들 입니다.
위에서 VSOutput으로 전달한 값 중 SV 태그가 붙은 시멘틱은 필수적으로 받고, 나머지 받고싶은 속성을 적어주면 됩니다. 물론 순서는 지켜줘야합니다(안지키면 오류남). 여기서는 단순하게 SV_POSITION과 COLOR를 받고 출력하고 있습니다.
픽셀 셰이더는 일단 여기까지만 진행하고, 나중에 SRV(t슬롯), MRT(멀티렌더타겟)을 활용할 때 자세히 알아보도록 하겠습니다.
[셰이더 오토 컴파일-1]
셰이더를 작성했으니, 사용하려면 당연히 컴파일을 진행해야합니다. 하지만 앞으로 다양한 셰이더들이 작성될 것이기 때문에 매 셰이더마다 따로 컴파일하고 값을 저장한 뒤 활용하기는 어려워 셰이더 오토 컴파일 기능을 추가했습니다.
일단 오토 컴파일을 위해 이전에 만들어준 Directory 기능을 활용해줍니다. 이걸 활용하면 경로를 찾아가서 존재하는 hlsl 파일들을 모두 확인하여 절대 경로를 만들어줄 것입니다.
또한 해당 프로젝트에서는 컴파일을 위해 한 가지 규칙이 필요한데, 바로 셰이더 파일 이름과 엔트리 포인트 이름이 동일해야한다는 것입니다. 예를 들어, Basic_VS라는 이름의 Vertex Shader를 만들었다고 가정해봅시다. 이러면 Vertex Shader 내부의 엔트리 포인트 이름도 Basic_VS로 해줘야 정상적으로 컴파일이 진행됩니다.
// 셰이더 정보 생성(Shader 오토컴파일, ConstantBuffer)
void Ext_DirectXResourceLoader::ShaderCompile()
{
// 셰이더 생성 규칙 : [1], [2]를 모두 만족해야 정상 컴파일 진행
// [1] 이름 + "_" + "Type" == Basic_PS
// [2] 내부 main(EntryPoint) 이름도 동일하게 설정
Base_Directory Dir;
Dir.MakePath("../Shader");
std::vector<std::string> Paths = Dir.GetAllFile({ "hlsl" });
for (const std::string& ShaderPath : Paths)
{
std::string EntryPoint = Dir.FindEntryPoint(ShaderPath);
Ext_DirectXShader::ShaderAutoCompile(ShaderPath, EntryPoint.c_str());
}
}
위의 기능을 호출하면 아래의 연관 함수들이 호출됩니다.
/////////////////////////////////////// Ext_DirectXVertexShader
Ext_DirectXVertexShader::Ext_DirectXVertexShader()
{
Type = ShaderType::Vertex;
}
void Ext_DirectXVertexShader::CreateVertexShader(std::string_view _Path, std::string_view _EntryPoint, UINT _VersionHigh, UINT _VersionLow)
{
CreateVersion("vs", _VersionHigh, _VersionLow);
EntryPoint = _EntryPoint;
unsigned int Flag = 0;
#ifdef _DEBUG
Flag = D3D10_SHADER_DEBUG;
#endif
Flag |= D3DCOMPILE_PACK_MATRIX_ROW_MAJOR;
COMPTR<ID3DBlob> Error;
std::wstring UniCodePath = Base_String::AnsiToUniCode(_Path);
if (S_OK != D3DCompileFromFile(UniCodePath.c_str(), nullptr, D3D_COMPILE_STANDARD_FILE_INCLUDE, EntryPoint.c_str(), Version.c_str(), Flag, 0, BinaryCode.GetAddressOf(), Error.GetAddressOf()))
{
// 에러를 텍스트로 출력
std::string ErrorString = reinterpret_cast<char*>(Error->GetBufferPointer());
MsgAssert(ErrorString);
return;
}
// 컴파일된 바이트코드로 GPU용 Vertex Shader 생성
if (S_OK != Ext_DirectXDevice::GetDevice()->CreateVertexShader(BinaryCode->GetBufferPointer(), BinaryCode->GetBufferSize(), nullptr, VertexShader.GetAddressOf()))
{
MsgAssert("버텍스 쉐이더 핸들 생성에 실패");
return;
}
// 상수버퍼 세팅, 리소스 세팅
ShaderResourceSetting();
}
/////////////////////////////////////// Ext_DirectXPixelShader
Ext_DirectXPixelShader::Ext_DirectXPixelShader()
{
Type = ShaderType::Pixel;
}
void Ext_DirectXPixelShader::CreatePixelShader(std::string_view _Path, std::string_view _EntryPoint, UINT _VersionHigh, UINT _VersionLow)
{
CreateVersion("ps", _VersionHigh, _VersionLow);
EntryPoint = _EntryPoint;
unsigned int Flag = 0;
#ifdef _DEBUG
Flag = D3D10_SHADER_DEBUG;
#endif
Flag |= D3DCOMPILE_PACK_MATRIX_ROW_MAJOR;
COMPTR<ID3DBlob> Error;
std::wstring UniCodePath = Base_String::AnsiToUniCode(_Path);
if (S_OK != D3DCompileFromFile(UniCodePath.c_str(), nullptr, D3D_COMPILE_STANDARD_FILE_INCLUDE, EntryPoint.c_str(), Version.c_str(), Flag, 0, BinaryCode.GetAddressOf(), Error.GetAddressOf()))
{
// 에러를 텍스트로 출력
std::string ErrorString = reinterpret_cast<char*>(Error->GetBufferPointer());
MsgAssert(ErrorString);
return;
}
// 컴파일된 바이트코드로 GPU용 Pixel Shader 생성
if (S_OK != Ext_DirectXDevice::GetDevice()->CreatePixelShader(BinaryCode->GetBufferPointer(), BinaryCode->GetBufferSize(), nullptr, PixelShader.GetAddressOf()))
{
MsgAssert("픽셀 쉐이더 핸들 생성에 실패");
return;
}
// 상수버퍼 세팅, 리소스 세팅
ShaderResourceSetting();
}
/////////////////////////////////////// Ext_DirectXShader
void Ext_DirectXShader::CreateVersion(std::string_view _ShaderType, UINT _VersionHigt /*= 5*/, UINT _VersionLow /*= 0*/)
{
// vs_5_0
Version += _ShaderType;
Version += "_";
Version += std::to_string(_VersionHigt);
Version += "_";
Version += std::to_string(_VersionLow);
}
// EntryPoint 기준으로, 셰이터 종류를 탐색
ShaderType Ext_DirectXShader::FindShaderType(std::string_view _EntryPoint)
{
std::string Lower = _EntryPoint.data();
if (Lower.find("_VS") != std::string::npos) return ShaderType::Vertex;
if (Lower.find("_PS") != std::string::npos) return ShaderType::Pixel;
if (Lower.find("_CS") != std::string::npos) return ShaderType::Compute;
if (Lower.find("_GS") != std::string::npos) return ShaderType::Geometry;
// if (Lower.find("_HS") != std::string::npos) return ShaderType::Unknown; // 또는 Hull
// if (Lower.find("_DS") != std::string::npos) return ShaderType::Unknown; // 또는 Domain
return ShaderType::Unknown;
}
// 셰이더 종류 기준으로 컴파일 진행
void Ext_DirectXShader::ShaderAutoCompile(std::string_view _Path, std::string_view _EntryPoint)
{
ShaderType Type = FindShaderType(_EntryPoint);
switch (Type)
{
case ShaderType::Vertex: Ext_DirectXVertexShader::LoadVertexShader(_Path, _EntryPoint); break;
case ShaderType::Pixel: Ext_DirectXPixelShader::LoadPixelShader(_Path, _EntryPoint); break;
case ShaderType::Compute: Ext_DirectXComputeShader::LoadComputeShader(_Path, _EntryPoint); break;
case ShaderType::Geometry: MsgAssert("Geometry 셰이더 타입은 아직 안만듬"); break;
case ShaderType::Unknown: MsgAssert("EntryPoint 탐색 실패"); break;
}
}
먼저 ShaderAutoCompile() 함수로 진입해서 해당 셰이더가 어떤 종류인지 파악하게 됩니다. Vertex Shader라면 LoadVertexShader(), Pixel Shader라면 LoadPixelShader()를 실행할 것입니다.
이렇게 이동하면 각자의 셰이더 종류에 따라 D3DCompileFromFile()을 실시한 뒤 Create_____Shader() 함수를 호출할 것입니다.
D3DCompileFromFile(
UniCodePath.c_str(), // [1] 셰이더 파일 경로
nullptr, // [2] #include 시 기본 처리
D3D_COMPILE_STANDARD_FILE_INCLUDE, // [3] 표준 인클루드 사용
EntryPoint.c_str(), // [4] 셰이더 진입 함수명
Version.c_str(), // [5] 셰이더 모델 버전 ("vs_5_0" 등)
Flag, 0, // [6,7] 컴파일 플래그
BinaryCode.GetAddressOf(), // [8] 성공 시 컴파일 결과 (바이트 코드)
Error.GetAddressOf() // [9] 실패 시 에러 메시지 반환
)
기존과 다른 것은 바로 리플렉션을 활용하여 상수 버퍼를 세팅하는 과정이 추가됐다는 것입니다. 해당 과정은 Create____Shader() 이후 ShaderResourceSetting()을 통해 자동으로 진행됩니다.
ID3D11ShaderReflection* CompileInfo = nullptr;
if (S_OK != D3DReflect(
BinaryCode->GetBufferPointer(), // [1] 컴파일된 HLSL 셰이더의 시작 주소
BinaryCode->GetBufferSize(), // [2] 바이트코드의 크기
IID_ID3D11ShaderReflection, // [3] 리플렉션 인터페이스 ID, ID는 __uuidof(ID3D11ShaderReflection)를 넣어도 되지만, IID_ID3D11ShaderReflection 플래그를 넣어도 동일하게 동작
reinterpret_cast<void**>(&CompileInfo))) // [4] 값을 전달받을 포인터
{
MsgAssert("쉐이더 리플렉션에 실패했습니다.");
return;
}
위의 함수 실행을 성공했다면 HLSL 셰이더 바인딩 슬롯에 정의된 리소스(지금은 상수버퍼만 있으니 cbuffer)의 이름, 크기, 바인딩 포인트 등을 런타임에 파악해줄 수 있습니다.
void Ext_DirectXShader::ShaderResourceSetting()
{
if (nullptr == BinaryCode)
{
MsgAssert("쉐이더가 컴파일 되지 않아서 쉐이더의 리소스를 조사할 수 없습니다.");
return;
}
// Reflection
// RTTI의 비슷한 개념으로
ID3D11ShaderReflection* CompileInfo = nullptr;
if (S_OK != D3DReflect(BinaryCode->GetBufferPointer(), BinaryCode->GetBufferSize(), IID_ID3D11ShaderReflection, reinterpret_cast<void**>(&CompileInfo)))
{
MsgAssert("쉐이더 리플렉션에 실패했습니다.");
return;
}
D3D11_SHADER_DESC Info;
CompileInfo->GetDesc(&Info);
D3D11_SHADER_INPUT_BIND_DESC ResDesc;
// 내가 사용한 상수버퍼 텍스처 샘플러등의 총합입니다.
for (UINT i = 0; i < Info.BoundResources; i++)
{
// 리소스 정보를 얻어오게 되고
CompileInfo->GetResourceBindingDesc(i, &ResDesc);
std::string Name = ResDesc.Name;
D3D_SHADER_INPUT_TYPE Type = ResDesc.Type;
std::string UpperName = Base_String::ToUpper(ResDesc.Name);
switch (Type)
{
case D3D_SIT_CBUFFER:
{
ID3D11ShaderReflectionConstantBuffer* CBufferPtr = CompileInfo->GetConstantBufferByName(ResDesc.Name);
D3D11_SHADER_BUFFER_DESC BufferDesc;
CBufferPtr->GetDesc(&BufferDesc);
std::shared_ptr<Ext_DirectXConstantBuffer> ConstantBuffer = Ext_DirectXConstantBuffer::CreateConstantBuffer(UpperName, BufferDesc, BufferDesc.Size);
ConstantBufferSetter Set;
Set.OwnerShader = GetSharedFromThis<Ext_DirectXShader>();
Set.Name = UpperName;
Set.BindPoint = ResDesc.BindPoint;
Set.ConstantBuffer = ConstantBuffer;
BufferSetter.InsertConstantBufferSetter(Set);
break;
}
// ...
}
}
Info.BoundResources를 통해 셰이더 내부의 리소스 갯수 만큼 반복문을 돕니다. ResDesc.Name으로는 이름을 알아낼 수 있고, ResDesc.Type는 상수 버퍼 종류가 무엇인지를 알아냅니다.
이러면 Type에 따라 switch문으로 들어가서 종류에 맞게 세팅을 진행합니다. 초기 값은 대부분 Null값을 넣어줍니다. 현재 상태에서는 Vertex Shader에 TransformData를 바인딩 해뒀기 때문에 D3D_SIT_CBUFFER로 들어갈 것입니다.
내부에서는 상수 버퍼를 생성(CreateBuffer())하고, ConstantBufferSetter라는 자료형에 값을 넣어주고 있습니다. ConstantBufferSetter는 그냥 유동적으로 상수 버퍼를 세팅하기 위해 해당 프레임워크에서 따로 만들어둔 클래스입니다. 내부는 아래와 같이 되어 있습니다.
#pragma once
// 상수버퍼 저장용 인터페이스
struct ConstantBufferSetter
{
std::string Name;
std::weak_ptr<class Ext_DirectXShader> OwnerShader;
int BindPoint = -1; // b0 t0 같은 몇번째 슬롯에 세팅되어야 하는지에 대한 정보.
std::shared_ptr<class Ext_DirectXConstantBuffer> ConstantBuffer;
const void* CPUData;
UINT CPUDataSize;
void Setting();
};
// 다양한 종류의 상수 버퍼를 저장하고 가져오기 위해 사용하는 클래스
class Ext_DirectXBufferSetter
{
friend class Ext_MeshComponentUnit;
public:
// constrcuter destructer
Ext_DirectXBufferSetter();
~Ext_DirectXBufferSetter();
// delete Function
Ext_DirectXBufferSetter(const Ext_DirectXBufferSetter& _Other) = delete;
Ext_DirectXBufferSetter(Ext_DirectXBufferSetter&& _Other) noexcept = delete;
Ext_DirectXBufferSetter& operator=(const Ext_DirectXBufferSetter& _Other) = delete;
Ext_DirectXBufferSetter& operator=(Ext_DirectXBufferSetter&& _Other) noexcept = delete;
// 생성된 상수버퍼 저장하기
void InsertConstantBufferSetter(const ConstantBufferSetter& _Setter) { ConstantBufferSetters.insert(std::make_pair(_Setter.Name, _Setter)); }
// 상수버퍼 데이터 저장
template<typename Type>
void SetConstantBufferLink(std::string_view _Name, const Type& _Data)
{
SetConstantBufferLink(_Name, reinterpret_cast<const void*>(&_Data), sizeof(Type));
}
protected:
private:
std::multimap<std::string, ConstantBufferSetter> ConstantBufferSetters; // 상수 버퍼 저장용 컨테이너
void SetConstantBufferLink(std::string_view _Name, const void* _Data, UINT _Size); // 상수버퍼 데이터 저장
void Copy(const Ext_DirectXBufferSetter& _OtherBufferSetter); // 버퍼 세팅 복사/붙여넣기 실시
void BufferSetting(); // 버퍼 세팅 호출
};
나중에 다양한 렌더링 파이프라인을 활용할 때, 해당 클래스를 통해 쉽게쉽게 바인딩해서 사용할 수 있도록 해뒀습니다. 이 부분은 나중에 실제로 사용할 때 따로 설명드리도록 하겠습니다.
문제의 상황에 대해 다시 설명드리자면, 화면의 종횡비와 NDC(Normalized Device Coordinates) 좌표 공간 차이로 인해 발생하는 것입니다. Direct3D에서 정점 셰이더까지의 최종 좌표는 NDC로 변환되고, 좌표계는 각각 다음의 범위를 가집니다.
축
범위
X
-1.0 ~ 1.0
Y
-1.0 ~ 1.0
Z
0.0 ~ 1.0(혹은 -1.0 ~ 1.0)
하지만 윈도우창은 1280x720로 생성되어 16:9의 종횡비를 가져, 가로가 세로보다 1.78배 더 긴 상태입니다. 때문에 실제 픽셀 매핑 시(ViewPort 변환 시) NDC의 X축 범위(-1.0 ~ 1.0)은 1280픽셀, Y축 범위(-1.0 ~ 1.0)는 720픽셀에 분배됩니다. 이로 인해 정사각형이 실제 화면에서는 가로로 늘어난 직사각형처럼 보이게 됩니다.
[카메라 생성]
해당 문제를 해결하기 위해서는 뷰, 프로젝션 행렬을 만들어서 사용해야합니다. 뷰포트 행렬은 기존에 RenderTarget에 정의한 것을 그대로 쓸 것입니다.
뷰(View)
- "시점"의 기준을 정의하는 행렬 - 뷰 스페이스 생성
프로젝션(Projection)
- 3D 물체를 2D 화면으로 투영하여 왜곡을 보정해주는 행렬 - Clip 스페이스 생성
뷰포트(Viewport)
- NDC 좌표를 실제 화면 픽셀 좌표로 변환하기 위한 행렬 - 이건 이전에 RenderTarget 만들 때 이미 만들었음
뷰, 프로젝션 행렬을 생성하기 위해서는 기준이 하나 필요합니다. 이를 위해 카메라라는 것을 생성했습니다. 해당 프레임워크에서 카메라는 Ext_Actor 클래스를 상속받으며, 내부에 Ext_Transform을 가지고 있습니다. 최초 생성 시에 따로 설정하지 않으면 위치 { 0.0f, 0.0f, 0.0f }, 회전 { 0.0f,0.0f,0.0f }, 크기 { 1.0f, 1.0f, 1.0f }의 데이터 값을 갖습니다. 이제 이 좌표를 기준으로 RenderTarget에 출력 결과물들이 그려질 것입니다.
[뷰 행렬(View Matrix)]
뷰 행렬은 월드 공간에 존재하는 모든 객체들을 카메라 기준으로 재배치해주는 행렬입니다. 3D상의 물체는 위치속성 뿐만 아니라 회전 속성도 보유하고 있습니다.
카메라가 (0, 0, 0)에 있고, 캐릭터가(0, 0, 100)에 위치해있으며, 카메라 시점이 30도 회전한 상태라고 가정해보겠습니다.
뷰 행렬은 여기서 카메라가 30도 회전한 것으로 만드는 것이 아니라, 카메라는 { 0, 0, 0 }에 위치시키고 회전값도 { 0, 0, 0 }으로 만들면서 나머지 물체들을 그만큼 이동시키는 것입니다.
EyePos는 카메라 현재 위치, EyeDir는 +Z축(전방), EyeUp은 +Y축(위)으로 전달했습니다. 이러면 카메라가 바라보는 시점으로 좌표계가 정의되면서 3D 카메라 공간(View Space)이 구축됩니다.
아래는 생성된 View Space에 대한 좌표값이 어떤 것을 의미하는지 나타내는 표입니다.
+X
오른쪽
-X
왼쪽
+Y
위
-Y
아래
+Z
앞
-Z
뒤
[프로젝션 행렬(Projection Matrix)]
뷰 행렬을 활용하면 Object들이 View Space에 정렬된 상태가 됩니다. 하지만 결국 모니터에 띄워야하는 화면은 2D 공간이기 때문에 3D 좌표값으로는 출력할 수가 없습니다. 따라서 View Space 좌표값을 2D 좌표값으로 투영시키기 위한 행렬이 필요합니다. 여기서 쓰이는 행렬이 바로 프로젝션 행렬입니다. 프로젝션 행렬이 적용되면 Object들은 Clip Space에 압축된 좌표값을 갖게 됩니다.
프로젝션의 방식은 직교(Othology)와 원근(Perspective)가 있는데, 지금은 원근 투영만 알아보도록 하겠습니다. 원근 투영 행렬의 생성은 DirectX의 XMMatrixPerspectiveFovLH() 함수를 활용합니다.
- 수직 시야각(Field of View; FOV)으로, 단위는 라디안(Radian)을 넣어줘야함 - 현재 프로젝트에서는 수직이 Y축 - 3D 렌더링 시스템은 AspectRatio(해상도)가 가변적이기 때문에 수직 시야각을 사용하고 가로 시야각은 종횡비로 보정하는게 일관성 유지에 유리하기 때문
float Aspect
- 종횡비(가로/세로) - ScreenWidth / ScreenHeight
float NearZ
- 가까운 클리핑 거리(Near Plane), 근평면이라고 함 - 이 거리보다 가까우면 렌더링에서 제외
float FarZ
- 먼 클리핑 거리(Far Plane), 원평면이라고 함 - 이 거리보다 멀면 렌더링에서 제외
함수 내부에서는 다음의 동작이 수행되면서 행렬이 만들어집니다.
float FovRad = _FovAngle * GameEngineMath::DegToRad; // 라디안으로 값 변환
float YScale = 1.0f / tanf(FOV / 2.0f); // y축 시야각이 클수록 값이 작아짐 → 멀리 보임
float XScale = YScale / _AspectRatio; // 가로 시야는 종횡비에 따라 정해짐
float ZRange = _FarZ - _NearZ; // Z 거리 범위 (Far - Near)
float Zn = _NearZ;
float Zf = _FarZ;
Arr2D[0][0] = XScale; // [1]
Arr2D[1][1] = YScale; // [2]
Arr2D[2][2] = Zf / ZRange; // [3]
Arr2D[2][3] = 1.0f; // [4]
Arr2D[3][2] = -Zn * Zf / ZRange; // [5]
Arr2D[3][3] = 0.0f; // [6]
// [1] X축 스케일링 → NDC 공간에서 좌우 시야 조절
// [2] Y축 스케일링 → NDC 공간에서 상하 시야 조절
// [3] Z값 정규화 (0~1로 변환하기 위한 깊이 매핑), FarZ에 가까울수록 값이 1에 가까워짐
// [4] 원근 분할을 위해 Z 값을 W로 복사 → 투영 후 divide 수행 가능하게 설정
// [5] 정규화된 Z값 계산을 위한 보정 항, -Zn * Zf / (Zf - Zn) 형태는 원근 보정된 깊이 값 계산에 사용됨
// [6] 투영 행렬 마지막 요소: 0, W 성분을 Z로부터 받아야 하므로 이 위치는 0으로 둬야 함
//////////////////////////////////////////////////////////////////////
////// 생성된 열(row) 기준 원근 투영 행렬
| XScale 0 0 0 |
| 0 YScale 0 0 |
| 0 0 Zf/(Zf-Zn) 1 |
| 0 0 -Zn*Zf/(Zf-Zn) 0 |
XScale, YScale, Zf/(Zf-Zn)으로 인해 Object의 X, Y값은 -1.0 ~ 1.0, Z값은 0.0 ~ 1.0으로 압축됩니다. 원근 투영은 헷갈리는 부분이 많기 때문에, 하나씩 차근차근 알아보도록 하겠습니다.
1. YScale
: 공식은 [float YScale = 1.0f / tanf(FOV / 2.0f)]입니다. 그림을 통해 알아보겠습니다.
같은 크기의 Object(파란색 사각형)일지라도 멀리 있을 수록 더 작게 보여야 합니다. 이때 Z값이 증가하면 그림과 같이 X, Y값도 증가하게 됩니다(뒤에 있을수록 약간씩 왼쪽으로 이동함). 이때 사각형의 위치는 탄젠트(tan)로 구할 수 있습니다.
// 탄젠트는 밑변에 대한 높이의 비
tan(θ) = 반대변 / 인접변
tan(θ) = (Y의 길이) / (Z의 길이)
tan(FOV / 2) = y / z
따라서, y = z * tan(FOV / 2)
+) FOV / 2인 이유는 그림에서 보시는 것과 같이 전체 FOV값에서 절반만 활용해서 값을 구하기 때문입니다.
이제 이 값을 원근 투영 행렬에 변환하기 위해 사용하는 것이 YScale = 1 / tan(FOV / 2) 입니다. y값을 압축해서 -1 ~ 1 범위로 맞추는 스케일 계수인 것입니다.
2. XScale
: X축 시야각은 Y축과 동일하게 구할 수 있지만, 추가로 종횡비를 나눠줍니다. 3D 렌더링 시스템은 AspectRatio(해상도)가 가변적이기 때문에 Y시야각(세로 시야각)을 고정으로 구하고 X시야각(가로 시야각)은 종횡비로 보정하기 때문입니다.
: Z값은 0.0 ~ 1.0 사이 값으로 압축해야 합니다. 하지만 이걸 직선적으로 매핑해버리면 원근 효과가 사라지게 됩니다. 그래서 원근 투영 행렬에서는 아래와 같이 비선형적으로 변환해줍니다.
z_clip = A * z + B ← 투영 행렬의 z 변환 결과
w_clip = z ← 투영 행렬의 w 성분
z_ndc = z_clip / w_clip = (A * z + B) / z
따라서 z_ndc = A + (B / z) ← 원근 분할 (Perspective Divide)
이렇게 나온 z_ndc 값으로 연립 방정식을 풀면 A와 B 값을 알 수 있습니다, NearZ일 경우에는 0, FarZ일 경우에는 1을 넣어서 풀어보면 됩니다.
//// NearZ의 경우
A + (B / Zn) = 0
→ A = -B / Zn
//// FarZ의 경우
A + (B / Zf) = 1
→ (-B / Zn) + (B / Zf) = 1
→ B * (-1/Zn + 1/Zf) = 1
→ B = 1 / ( -1/Zn + 1/Zf ) = (Zn * Zf) / (Zf - Zn)
이러면 A값은 다음과 같습니다.
A = -B / Zn = - (Zf) / (Zf - Zn)
따라서 A(Projection[2][2]) = Zf / (Zf - Zn)
4. Arr2D[3][3] = 0.0f
: w 성분 계산 시의 상수 항으로, w_clip = z 값을 보존하기 위해(이외에는 아무것도 더해지지 않도록) 0.0f 값을 갖습니다.
해당 단계는 Rasterizer에서 수행됩니다. W Divide가 수행되기 때문에 Z값이 크면 클수록(시점에서 멀어지면 멀어질수록) 크기가 작아지게 됩니다.
Clip Space 정점들이 다음의 값을 갖는다고 가정해봅시다.
- A: (2, 2, 2, 2)
- B : (2, 2, 10, 10)
- C : (2, 2, 50, 50)
이러면 A는 이후 2로, B는 10으로, C는 50으로 각 요소를 나눠줘야합니다.
- NDCA : (1.0, 1.0, 1.0)
- NDCB : (0.2, 0.2, 0.2)
- NDCC : (0.04, 0.04, 0.04)
확인할 수 있는 것과 같이, X, Y 크기가 같아도 Z값이 다르면 크기가 달라지는 것을 확인할 수 있습니다.
[뷰포트 행렬]
프로젝션 행렬을 통해 Clip Space 좌표계로 값이 변경된 후, Rasterizer에서 W 나누기를 수행하면 NDC 좌표계로 값이 변경됩니다. 이제 이 Object 좌표값을 픽셀 공간(Screen Space)로 매핑해주기 위해 뷰포트 행렬을 활용합니다.
하지만 DirectX11에서는 뷰포트 행렬을 구하는 함수를 따로 지원하지 않습니다. 왜냐하면 하드웨어 고정 기능으로 처리하기 때문입니다. 우리는 이것을 RenderTarget을 만들 때 이미 정의해줬으며, 바인딩은 RSSetViewports() 함수를 호출하여 수행해줬습니다.
void Ext_DirectXRenderTarget::RenderTargetSetting()
{
COMPTR<ID3D11RenderTargetView> RTV = Textures[0]->GetRTV(0);
if (nullptr == RTV)
{
MsgAssert("랜더타겟 뷰가 존재하지 않아서 클리어가 불가능합니다.");
}
COMPTR<ID3D11DepthStencilView> DSV = DepthTexture->GetDSV();
if (nullptr == DSV)
{
MsgAssert("뎁스스텐실뷰 왓");
}
Ext_DirectXDevice::GetContext()->OMSetRenderTargets(static_cast<UINT>(RTVs.size()), RTV.GetAddressOf(), DSV.Get());
Ext_DirectXDevice::GetContext()->RSSetViewports(static_cast<UINT>(ViewPorts.size()), &ViewPorts[0]);
}
단순하게 하나의 결과물만 출력한다면 이전 포스팅까지 진행했던 방식대로 하나씩 출력해보면 되지만, 궁극적으로는 게임 프레임워크가 어떻게 이루어져있는가와 더불어 그래픽스 학습을 위해 만들고 있는 프레임워크이기 때문에(물론 이 프레임워크로 게임을 제작하지 않을 수도 있습니다. 이외에 할 게 많기 때문입니다) 렌더 결과물을 하나씩 출력하거나, 출력 결과물마다 렌더링 파이프라인을 일일이 설정해줄 수 없는 노릇이었습니다.
이에 따라 필요한 기능들을 추가했고, 다음과 같은 결과를 얻을 수 있었습니다.
아마 25.05.22~23 쯤일주일 후, 25.05.28~29일쯤
[프로젝트 구성]
추가된 기능들을 간단하게 살펴보기에 앞서, 현재 프레임워크가 어떻게 이루어져 있는지, 어떤 흐름을 가지는지에 대해 간략하게 설명드리고 넘어가겠습니다. 프레임워크의 프로젝트 구성은 다음과 같습니다.
DirectX11_Base
- 가장 상위 프로젝트 - 프레임워크에 기본적으로 필요한 기능들을 담아둠(윈도우창 생성, float4 자료형, 델타타임 등) - 빌드하면 정적 라이브러리(,lib)가 됨
DirectX11_Extension
- 프레임워크 Loop간 필요한 기능과 DirectX 관련 기능 모음 프로젝트 - 사실 이름을 Extention이 아니고 Engine이라고 지었어야 하지 않았을까? - 빌드하면 정적 라이브러리(,lib)가 됨
DirectX11_Contents
- 사용자를 위한 프로젝트 - 여기서 Actor, Scene, gui 등 자유롭게 만들고 테스트하면 됨 - 빌드하면 정적 라이브러리(,lib)가 됨
DirectX11_RenderingPipeline
- 시작 프로젝트 - exe 파일로 빌드되어 이걸로 실행하면 프로세스 동작
Shader
- 필요한 셰이더 목록
ThirdPaty
- DirectXTK : 텍스쳐 로드용 - assimp : 메시 로드용 - imgui : 사실 여기 있어야하는데, 그냥 Extension 프로젝트에 넣어놨다.
+) 사실 DirectX11_Extension 프로젝트의 경우, DirectX11_Engine이라는 이름이 맞습니다만, 작명 실수입니다. 이걸 고치기에는 너무 먼 길을 와버려서 그냥 넘어가기로 했습니다.
[프레임워크 Flow]
프레임워크의 전체적인 흐름은 다음과 같습니다.
Window Create
- exe 파일 시작 시, 윈도우창 생성 - 생성 즉시 Window Loop 진입
Level(Scene) Create
- Engine Start 부분에서 필요한 Scene이 있다면 Create 실시 - Scene이 생성됨과 동시에 Ening Core에 저장 - 여기서 Actor랑 Component 생성해도 됨
Program Loop Check
- 윈도우창이 파괴될 경우 프로세스 종료로 진입, 아니라면 루프 진행
Level(Scene) Change Check
- Scene이 변경됐는지 체크, 변경됐다면 해당 레벨의 SceneChangetStart를 실시하고, 이전 Scene은 SceneChangeEnd를 실시하여 설정 변경 - Scene 변경 시 필요한 Actor, Component 생성 및 저장 - 생성된 Actor와 Component는 Window Loop에서 Update 실시
Deltatime Calculation
- Deltatime을 산출 - 현재 프로세스는 60FPS로 제한된 상태(따로 설정함)
Level(Scene) Update
- 선택된 Scene의 Update 실시
Actor Update
- 선택된 Scene에 존재하는 Actor와 Component의 Update 실시 - 이동, 피격(아직 안함) 등 설정, 이외 업데이트 시 실행할 동작들을 정의(카메라 포함)
Rendering Setting
- 렌더 타겟 뷰 클리어 및 세팅 - 카메라 뷰, 프로젝션, 뷰포트 행렬 계산 - Actor에 부착된 MeshComponent의 Rendering Setting 진행 - Rendering Pipeline으로 설정된 옵션별로 세팅 진행 - 이후 DrawIndexed로 렌더타겟뷰에 모두 그려줌
Present
- 렌더타겟뷰를 Present하여 화면에 출력
Release
- 위에는 표시 안되어있지만, 루프 한번 돈 이후 만약 Destroy를 호출한 액터나 컴포넌트가 있다면 제거
Window End
- 윈도우 종료
+) 이후 Destroy 기능을 추가해서 Present 이후 단계에서 Release 단계를 실시합니다. 이전에 Destroy를 호출한 Actor나 Component가 있다면, Release 단계에서 지워줍니다(메모리에서 소거).
[추가된 기능들]
1. Scene
: 액터와 컴포넌트, 카메라를 관리하는 클래스입니다. 여러 개가 존재할 수 있으며 자신에게 포함된 액터, 컴포넌트, 카메라의 라이프사이클을 관리합니다.
2. Actor
: 트랜스폼을 갖는 오브젝트입니다. 이동, 회전, 크기 변경이 가능하고 컴포넌트를 탈부착하여 기능을 확장할 수 있습니다.
3. Component
: 액터에 탈부착하는 객체입니다. 마찬가지로 트랜스폼을 가지고, 액터에 부착되면 트랜스폼을 통해 액터의 자식으로 등록됩니다. 이러면 액터의 트랜스폼을 부모 트랜스폼으로 인식하고, 이동, 회전, 크기 변경 대해 액터의 영향을 받게 됩니다. 저의 프로젝트에서는 주로 Mesh를 위한 컴포넌트로 설계되어있는데, 충돌체나 물리 컴포넌트 등 다양한 기능들을 탈부착할 수도 있습니다(아마 안할듯..).
4. Transform
: 액터와 컴포넌트의 기하구조를 위한 클래스입니다. Local Matrix(로컬 행렬)과 World Matrix(월드 행렬)로 나뉘며, 부모가 있을 때와 없을 때로 나뉩니다.
> 부모가 없을 때 = 로컬 행렬이 지정되면 그건 바로 월드 행렬이 됩니다(로킬이 곧 월드).
> 부모가 있을 때 = 로컬 행렬이 지정되고 부모 월드 행렬의 영향을 받아 부모+자신으로 월드 행렬이 생성됩니다. 여기에 대해선 많은 옵션이 있을 수 있지만, 기본적으로는 이것만 만들어두었습니다(크게 쓸 일이 없을 것으로 예상됨)
5. Input
: 간단한 카메라 조작을 위해 Input을 관리하는 클래스를 만들었습니다. 매 프레임 시작 부분에서 GetAsyncKeyState()로 입력이 발생했는지 확인합니다. 입력이 발생했다면 눌렀는지, 누르고 있는지, 안누르는지만 확인해줍니다.