스카이박스에서 큐브맵을 로드하여 활용해봤습니다. 큐브맵을 여기서만 쓰는게 아니고, 리플렉션 프로브를 위해 사용할 수도 있습니다. 리플렉션 프로브는 3D 실시간 렌더링에서 간접 반사를 구현하기 위한 기술로, 오브젝트 표면에 환경이 반사되는 것처럼 보이게 만드는 데 사용됩니다.
실시간 반사는 성능 부담이 크기 때문에, 리플렉션 프로브를 활용하여 특정 위치의 반사 환경을 미리 저장해두고, 주변 오브젝트에 이 큐브맵을 적용하여 효율적으로 간접 반사를 구현할 수 있습니다.
기존의 RTV, SRV를 만들던 것과 차이가 있다면, MiscFlags 값을 D3D11_RESOURCE_MISC_TEXTURECUBE로 지정해주는 것과 아래와 같이 D3D11_SUBRESOURCE_DATA에 값을 6번 넣어주고, 그걸로 Textrue2D를 Create()하는 것 정도 아닐까 싶습니다.
SkyBox는 기존에 렌더러들을 출력해주는 RenderTarget에 그리지 않고, 따로 RenderTarget을 만들어 거기에 먼저 그려둔 뒤, 나중에 최종 RenderTarget(CameraRenderTarget)에 Blending 했습니다. 먼저 Material Setting입니다.
9x9까지 해버리면 연산량이 너무 많아집니다. 이에 대한 해결책으로 [원본 이미지 다운 스케일링 -> 5x5정도의 필터 적용 -> 적용된 이미지 원래 크기로 업스케일링]을 실시할 수도 있습니다(효과는 동일한데 연산은 최적화됨). 여기서는 적용에 의의를 두는 것이기 때문에 5x5, 7x7, 9x9를 각각 실시해봤습니다.
2. OldFilm
: 특수한 텍스쳐를 사용했습니다.
예전에 Cuphead라는 게임을 모작할 때 사용했던 리소스인데, 똑같이 가져와서 적용해봤습니다.
렌더링을 하기 위해서는 렌더링 파이프라인 개념을 알아야합니다. 렌더링 파이프라인은 Direct3D에서 메모리 자원들을 GPU로 처리하여 하나의 렌더링 이미지로 만드는 일련의 과정을 말합니다.
일련의 과정에서 크게 두 가지로 나눌 수 있기도 합니다. 고정 기능 단계와 프로그래밍 가능 단계입니다.
- 고정 기능 단계 : 미리 정해진 특정 연산들이 수행되고, 상태 객체라는 개념을 이용하여 연산의 설정을 변경할 수 있으며, 사용자가 임의로 실행을 거부할 수 없음
- 프로그래밍 가능 단계 : HLSL(High Level Shading Languege)로 셰이더 프로그래밍이 가능한 단계이며, 임의로 실행을 거부할 수 있음
[단계별 정리]
단계별 핵심 내용입니다. "렌더링 결과물을 출력한다"를 위해서는 Input Assembler, Vertex Shader, Rasterizer, Pixel Shader, Output Merger만 진행하면 됩니다. Hull Shader, Tessellator, Domain Shader, Geometry Shader는 인스턴싱을 위한 과정입니다. 대표적으로 파티클이 있습니다만, 제가 사용할 프레임워크에서는 크게 다루지 않을 것 같습니다.
Input Assembler (입력 조립기 단계)
- Vertex Buffer / Index Buffer에 저장된 정점, 인덱스 데이터 수집 - 이 단계에서는 아직 쉐이더가 실행되지 않았으며, 파이프라인에 데이터가 들어오는 입구에 해당 - 설정 정보에는 [Input Layout], [Primitive Topology] 등도 포함
<Index Buffer 활용> : Vertex Buffer와 관련이 깊으며, 정점 버퍼에 있는 각 정점의 위치를 기록하기 위해 활용 : 이를 통해 Vertex Buffer의 특정 정점(Vertex)을 빠르게 찾거나 정점을 재사용 가능하게 만들어주며, Input Assembler 단계에서 사용 : 예시) 삼각형 하나를 이루기 위한 인덱스: 0, 1, 2 → 정점 0~2를 참조 : 사용 시 중복 정점 제거로 메모리를 절약하며, 그리기 명령 효율성 증가
<Vertex Buffer 활용> : 정점(Vertex)들의 위치, 노멀, UV, 색상 등(float3 Postion, float3 Normal, float3 TexCoord)의 속성을 담고 있는 버퍼로, Input Assembler에서 활용됨 : IASetVertexBuffers()로 설정 → Vertex Shader
Vertex Shader (정점 셰이더)
- 각 정점 데이터를 변환하는 첫 번째 쉐이더 - 정점마다 월드 좌표 → 클립 좌표로 변환, 애니메이션 스키닝, 라이팅 계산 등의 처리 수행 - 여기서 수행된 결과는 Pixel Shader에서 사용할 수 있도록 전달
<Constant Buffer 활용> : 셰이더에 값을 저장하는 메모리로, CPU ↔ GPU간 데이터를 전달에 사용되며, 모든 셰이더 단계에서 활용됨 : 해당 값에는 행렬(World, View, Projection 등) 정보 등 활용 : 최대 14슬롯 바인딩이 가능하며, 내부 데이터는 16byte 단위로 정렬 필요
<Sampler 활용> : 샘플러를 쓰기도 하지만, 주로 픽셀 셰이더에서 씀
Hull Shader (덮개 셰이더) [생략 가능]
- 테셀레이션의 첫 번째 단계 - 패치(기본 도형) 단위로 테셀레이션 정도를 결정하기 위해 테셀레이션 계수를 계산 - 쉐이더가 실행되면 정점 그룹 단위로 계산되며, 곡면 분할이 필요한 경우 사용
<Constant Buffer 활용>
Tessellator (테셀레이터) [생략 가능]
- 고정 기능 하드웨어 - 직접 프로그래밍은 불가능하며, Hull Shader에서 제공된 정보를 기반으로 세분화된 정점을 생성 - 삼각형, 선, 사각형 형태로 분할 가능
Domain Shader (영역 셰이더) [생략 가능]
- 테셀레이터가 생성한 세분화된 정점의 위치와 속성을 계산 - 곡면의 실제 형상 및 기하 정보를 계산하며, 여기서 생성된 정점은 이후 Geometry Shader로 전달
<Constant Buffer 활용>
Geometry Shader (기하 셰이더) [생략 가능]
- 도형(Primitive) 단위의 생성, 제거, 변형 - 삼각형을 분해하거나 더 추가할 수 있고, 쉐도우 볼륨, 실루엣, 아웃라인 효과 등에 활용 - Stream Output을 통해 이 결과를 바로 GPU 버퍼에 저장할 수도 있음
<Stream Output (스트림 출력)> : Geometry Shader 출력 결과를 GPU 메모리에 직접 저장(Geometry Shader → Stream Output → 버퍼 저장) : 파티클, 물리 시뮬레이션, LOD 캐싱, 후속 드로우콜 등에 활용 : 렌더링 외의 데이터 가공 목적에도 활용
<Constant Buffer 활용>
Rasterizer (레스터라이저)
- 벡터 → 픽셀 변환 (Triangle → Pixel) - 화면 공간으로 변환된 정점 데이터를 픽셀 단위로 분해 - Z-버퍼, 컬링, 클리핑 등의 테스트가 여기서 수행
Pixel Shader (픽셀 셰이더)
- 화면에 찍힐 각 픽셀의 색상 계산 - 텍스처, 라이팅, 그림자, 블렌딩, 쉐도우맵 등 다양한 시각 효과 구현의 중심 - 결과는 렌더 타겟으로 전달
<Constant Buffer 활용> : 정점 셰이더에서 Transform 데이터를 주로 활용한다면, 여기는 텍스쳐, Structured(빛, 델타타임) 등을 활용
<Sampler 활용> : 텍스처 필터링이나 경계 처리 방식 등을 정의한 값으로, Vertex Shader나 PixelShader에서 텍스처 샘플링 시 활용됨 : 상수버퍼와 마찬가지로 바인딩 슬롯으로 데이터가 전달되고 사용됨 : UV값에 WRAP, CLAMP 등을 설정 : SetSamplers()로 최대 16개까지 바인딩 가능
<Texture Buffer 활용> : 텍스처, 노이즈맵, 그림자 맵 등 샘플링 가능한 데이터 자원이며, Pixel Shader, Compute Shader 등에 활용됨 : Diffuse, Normal, Shadow Map 등이 있으며 SRV(Shader Resource View)로 접근 가능함 : 최대 128개까지 바인딩 가능
Output Merger (출력 병합)
- 최종 픽셀 결과를 렌더 타겟, 깊이/스텐실 버퍼에 기록 - Z-Test, 스텐실 테스트, 블렌딩 등을 수행하며 최종 출력 픽셀을 결정 - 모든 테스트를 통과한 픽셀만 화면에 그려짐
렌더될 대상들에 대해 Pixel Shader에 View, Projection 행렬을 곱한 Clip Space 값을 전달해줍니다. 이러면 깊이를 만들기 위해서 z에 w divide를 수행해야합니다. 그 점을 이용하여 전달받은 값에 대해 z를 w로 나눠 R채널에 깊이값을 기록해줍니다.
하나의 RenderTarget 내에서 위의 블렌딩 공식을 사용하기 때문에, 현재 값보다 더 작은 값이 RenderTarget에 기록됩니다. 아래의 그림을 확인해보겠습니다.
빛이 45도 각도로 네모, 삼각형, 동그라미를 내리쬐는 형태로, 순서대로 네모, 세모, 동그라미, 오각형이 그러지며, 모두의 깊이값이 대략 0.45부터 0.8 사이 정도에 존재한다고 가정해보겠습니다.
1. 네모
: 아무것도 그려지지 않았기 때문에 그냥 그려지면서, 깊이값을 기록합니다.
2. 세모
: 1번(빨간점)부터 2번(보라점)까지의 범위가 네모와 겹칩니다. 해당 부분의 깊이값을 봤을 때, Min Blending을 실시하기 때문에 값이 더 작은 깊이값이 기록됩니다. 네모는 대략 0.5, 세모는 대략 0.7이라고 가정한다면, RenderTarget의 현재 위치는 네모의 0.5가 기록됩니다.
3. 동그라미
: 동그라미가 그려졌는데, 3번(초록점)부터 4번(하늘색점)까지의 범위가 기존에 그려졌던 세모와 겹칩니다. 똑같이 Min Blending을 실시하기 때문에 값이 더 작은 깊이값이 기록됩니다. 동그라미는 대력 0.6, 세모는 대략 0.72이라고 가정한다면, RenderTarget의 현재 위치는 동그라미의 0.6으로 갱신됩니다.
4. 오각형
: 자기 외에 아무것도 그려지지 않은 상태이기 때문에, 깊이값을 자신의 것으로 기록합니다.
5. 이외
: 이외에는 깊이값이 기록되지 않기 때문에 원래의 렌더 타겟 색상(1.0f, 0.0f, 0.0f, 1.0f)을 갖게 됩니다.
최종적으로 그려지는 결과는 다음과 같습니다.
이렇게 생성되는 것이 바로 그림자 맵입니다. Ext_Camera의 Rendering() 함수 중, 아래의 부분에서 지금까지 설명드린 과정이 진행됩니다.
// 카메라의 MeshComponents들에 대한 업데이트 및 렌더링 파이프라인 리소스 정렬
void Ext_Camera::Rendering(float _Deltatime)
{
// ...
// Geometry Pass 진행하는 부분
// 쉐도우 패스, 뎁스 만들기
auto& Lights = GetOwnerScene().lock()->GetLights();
for (auto& [name, CurLight] : Lights)
{
std::shared_ptr<Ext_DirectXRenderTarget> CurShadowRenderTarget = CurLight->GetShadowRenderTarget();
if (!CurShadowRenderTarget) continue; // 세팅 안되어있으면 그릴 필요 없음
std::shared_ptr<LightData> LTData = CurLight->GetLightData(); // 현재 라이트의 데이터(앞서 업데이트됨)
CurShadowRenderTarget->RenderTargetSetting(); // 백버퍼에서 지금 렌더 타겟으로 바인딩 변경, 여기다 그리기
// 쉐도우 뎁스 텍스쳐 만들기
for (auto& Unit : AllRenderUnits)
{
if (!Unit->GetIsShadow()) continue;
Unit->GetOwnerMeshComponent().lock()->GetTransform()->SetCameraMatrix(LTData->LightViewMatrix, LTData->LightProjectionMatrix); // 라이트 기준으로 행렬 세팅
Unit->RenderUnitShadowSetting();
std::shared_ptr<Ext_DirectXMaterial> ShadowPipeLine;
if (ShadowType::Static == Unit->GetShadowType())
{
ShadowPipeLine = Ext_DirectXMaterial::Find("Shadow");
}
else if (ShadowType::Dynamic == Unit->GetShadowType())
{
ShadowPipeLine = Ext_DirectXMaterial::Find("DynamicShadow");
}
else
{
MsgAssert("여기 들어오면 안되는데 뭔가 잘못됨");
}
ShadowPipeLine->VertexShaderSetting();
ShadowPipeLine->RasterizerSetting();
ShadowPipeLine->PixelShaderSetting();
ShadowPipeLine->OutputMergerSetting();
Unit->RenderUnitDraw();
}
}
// ...
// Deferred 작업 진행하는 부분
}
그림자를 활성화할 렌더 대상들의 경우, 아래와 같이 ShadowOn() 함수를 호출해주도록 했습니다.
이제 두 값을 비교해서 현재 픽셀이 Shadow Map보다 더 뒤에 있으면(가리지 않으면) 그림자가 됩니다. 깊이값은 더 큰게 뒤에 있는 것입니다. [LightProjection.z ≥ fShadowDepth + Bias] 조건식을 수행하여 그림자 영역을 찾아주고, 해당 부분은 float4(1.0f, 0.0f, 0.0f, 1.0f)로 기록해줍니다(R채널 말고 G, B채널 아무대나 해도 상관 없습니다).
여기서 한 가지 특이한 점이, fShadowDepth 값에 0.001f를 더해주고 있는 것입니다. 이건 Depth Bias라고 하는데, Self_Shadowing을 막기 위해 현재 Fragment Light 공간 깊이에 아주 작은 Offeset을 더해주는 행위입니다. GPU에서 깊이에 기록되는 값은 부동소수점이고, 이 부동소수점은 정밀도에 한계가 있어 실제 렌더되는 대상의 위치와 완전히 일치하지 않을 수도 있습니다.
또한 Bias 없이 [LightProjection.z ≥ fShadowDepth]만 수행한다면, 같은 표면 위에 있는 픽셀도(혹은 +- 0.0000001) [>] 판정이 되어 표면 위에 검은 줄이 생길 수도 있습니다.
결과는 다음과 같습니다. 왼쪽 위는 최종 결과이니, 왼쪽 아래 결과를 보시면 됩니다.
[출력하기]
이제 기록된 값을 활용해주면 됩니다. 해당 부분은 모든 Light Buffer 값들을 Merge 해주는 곳입니다.
샘플링을 실시하면 특정 픽셀에 대해서는 R채널(x) 값이 1로 나옵니다. 해당 위치는 그림자 영역이기 때문에, 음영이 지도록 처리해주면 됩니다. 여기서는 단순하게 해당 영역에 대해서는 Diffuse와 Specular Light 값에 Shadow Factor라고 해서 약간의 값을 처리해줬습니다. 1 이하의 값이기 때문에 곱해주면 원래 빛의 Color 보다 어두워질 것입니다.
+) 저는 약간의 노가다를 해서 원래 Light 들로 생겨났던 음영과 비슷한 강도로 설정했습니다.
최종 결과를 확인해봅시다.
+) 여기서는 따로 언급을 안했는데, Dynamic의 Shadow의 경우, 그림자맵을 만들 때 Vertex Shader에서 기존과 같이 스키닝을 실시해줘야 합니다. 아마 안하면 T-Pose를 취한 상태의 Maximo 캐릭터가 공중에 떠있는 그림자가 생성될 것입니다.
드로우 콜(Draw Call)은 CPU가 GPU에게 오브젝트를 그리라고 명령을 내리는 것입니다. 자세히는 GPU의 Render State를 변경하는 작업입니다. GPU는 혼자서 아무것도 할 수 없습니다. 오브젝트를 그릴 때 조차 어떤 텍스쳐를 써야하는지, 어떤 셰이더를 써야하는지가 정해져 있지 않아서 CPU가 이것을 알려줘야합니다. 이렇게 알려주면(Draw Call) GPU는 이제 그것에 따라서 세팅된 값(Render State)을 그대로(굉장히 빠르게) 처리합니다.
물론 이 Render State에 들어갈 값들은 이미 GPU Memory에 존재해야 합니다(VRAM). 우리는 이미 이 값들이 무엇인지 알고 있습니다.
- InputLayout
- Vertex Buffer
- Index Buffer
- Constant Buffer(Texture, Sampler)
- Vertex Shader
- Pixel Shader
- DepthStencilState, BlendState, RasterizerState
이 값들을 DirectX11의 함수를 통해 바인딩 해주어 데이터 시작 주소를 가져가 VRAM에 Copy/Paste 해준 뒤, GPU가 이대로 그릴 수 있도록 해줬습니다. 이후 마지막 단계에서 이 값들을 가지고 Draw() 함수를 호출하여 오브젝트를 그리게 됩니다.
하지만 이 Render State를 바꾸는 과정은 아주 많은 오버헤드가 발생합니다. CPU와 GPU가 사용하는 언어가 다른데, 이걸 번역해주는 과정이 필요하기 때문입니다. 보통 이 번역은 우리가 GPU를 위해 설치하는 GPU Driver 내 Code 들이 실시해줍니다. 결국 번역 때문에 시간이 걸리는 것인데, CPU가 Vsync 1회(보통 33ms) 안에 값을 정상적으로 전달해줘야 되지만, 가끔은 CPU 자체의 전달 과정이 오래 걸리거나 번역에 이상이 생겨 지연될 경우 Bottle Neck 현상이 발생하기도 합니다.
[드로우 콜 줄이기]
당연히 성능을 위해 많은 최적화 기법들이 생겨났습니다.
1. 배칭(Batching)
: 여러 메시를 하나의 버퍼에 묶어서 한 번에 그리는 방법입니다. 여기에는 정적 메시를 미리 합쳐두는 Static Batching과, 매 프레임 동적으로 유니폼 버퍼 값만 바꿔서 그리는 Dynamic Batching이 있습니다.
- 런타임 오버 헤드 없음
- 병합된 하나의 버퍼만 바인딩하여 드로우 콜이 1회가 됨
- 병합된 버퍼 크기 증가 시 메모리 사용량이 상승
- 스태틱 배칭의 경우 병합된 메시를 모두 활용하기 때문에 메모리 사용량이 증가하며, 개별 컬링이 불가능함, 변형이 필요한 경우 다시 병합해야 하므로 유연성이 낮음
- 다이나믹 배칭의 경우 실시간으로 연산하기엔 연산량이 비대하여 버텍스 수가 많으면 오히려 배칭으로 발생하는 손해가 더 커짐
2. 인스턴싱(Instancing)
: 동일한 메시가 있다면, 이걸 개별로 드로잉하지 않고 여러 위치, 트랜스폼으로 동시에 그려버릴 수 있습니다. 이걸 인스턴싱이라고 합니다. 인스턴스별로 행렬 정보와 머티리얼 값만 다르게 전달해주면 됩니다.
- 오브젝트 수 만큼 드로우콜이 감소(10개 그릴걸 하나에 몰아서 동시에 그리기 때문)
- API 호출을 오브젝트 수만큼 하지 않아서 드로우 콜 오버헤드 감소
- 버텍스 데이터가 한 곳에 있고, 인스턴스 데이터가 연속된 배열로 감싸져 있어서 캐시 로컬리티 좋음
- 너무 크면 메모리가 낭비되고, 너무 작으면 여러 번 분할 업데이트 되기 때문에 적당한 크기를 찾아야함
3. 텍스쳐 아틀라스(Atlas)
: 여러 텍스쳐를 하나의 큰 텍스쳐에 합쳐서 활용하는 방법입니다. 이러면 셰이더에서 UV 오프셋만 조정해서 다양한 텍스쳐처럼 사용할 수 있어 Texture 바인딩 횟수가 감소하게 됩니다.
- 다른 텍스쳐 바인딩을 따로하지 않고 같이 해서 사용하여 드로우콜이 1회로 감소
- 셰이더 리소스 바인딩 횟수가 줄어 오버헤드 감소
- 텍스쳐 샘플링 시 한 덩어리 텍스쳐에서 샘플링하므로 메모리 로컬리티 좋음
- 텍스쳐 경계에서 이웃 픽셀이 섞일 수록, 각 서브 텍스쳐 사이에 1-2px의 패딩을 넣지 않으면 블리딩(Bleeding) 현상 발생
- 자동 생성된 MipMap이 경계 영역을 섞을 수 있도록, 런타임에 수동으로 MipMap을 만들어거나 패딩을 충분히 확보해야함
- GPU마다 지원하는 최대 텍스쳐 크기가 있어서 서브 텍스쳐 갯수와 크기에 맞춰서 설계해야함
4. 프로스트럼, 오클루전 컬링
: 카메라 시야 밖이나 가려진 오브젝트를 배제하는 방법으로, DirectX에서는 Rasterizer 단계에서 자체적으로 실행되기도 하며, 사용자가 임의로 오브젝트를 컬링하는 방식도 있습니다.
5. LOD(Level of Detail)
: 멀리 있는 객체는 폴리곤 수가 적은 메시로 치환하는 방법입니다. 메시의 폴리곤 수가 적으면 적을수록, 이 메시를 표현하기 위해 사용되는 GPU 자원이 적어지기 때문에 드로우 콜 과정이 단순화됩니다.
- 멀리 있는 오브젝트는 픽셀 당 차이가 눈에 보이지 않아, 단순화 시켜서 드로우 연산 자체 부담을 줄임
- 복잡한 씬에서도 가까운 오브젝트만 정밀하게 그리고 안보이거나 제대로 안봐도 되는 경우에는 간단하게 렌더링하여 퍼포먼스 최적화
- 하지만 전환 지점이 너무 짧으면 Popping 현상이 발생(페이드 블렌드, 크로스 페이드 적용 필요)
이후의 단계로 그림자 매핑과 포스트 프로세싱을 진행하려고 하는데, 제가 알고있는 방식이 디퍼드 렌더링 방식이라 조금 고민을 했습니다. 그래도 애초에 프레임워크 제작 이유가 그래픽스 학습과 DirectX11 복습이었기 때문도 있고 시간적인 여유가 부족했기 때문에 그냥 디퍼드 렌더링으로 변경하여 다음 단계를 진행하기로 마음먹었습니다.
[변경 결과]
우선 변경 결과입니다.
기존의 결과와 크게 달라진 건 없고, 구조적인 부분만 변경되었습니다. 내용이 상당히 길기 때문에 천천히 읽어보시면 될 것 같습니다.
[구조 변경]
1. fx 파일 생성
: G-Buffer를 만들기 전에, 공통적으로 사용하는 메모리 슬롯이나 struct 부분에 대해서 따로 fx 파일을 만들어서 저장해두었습니다.
: 업데이트 과정이 변경되었습니다. 그게 Geometry Pass, ShadowMap Pass, Light Pass로 나뉩니다.
void Ext_Camera::Rendering(float _Deltatime)
{
MeshRenderTarget->RenderTargetClear();
MeshRenderTarget->RenderTargetSetting();
////////////////// 일반 패스(Mesh, Position, Normal Buffer) ///////////////
// 전체 유닛 Z정렬 후 렌더링
std::vector<std::shared_ptr<Ext_MeshComponentUnit>> AllRenderUnits;
for (auto& [RenderPathKey, UnitMap] : MeshComponentUnits)
{
for (auto& [IndexKey, UnitList] : UnitMap)
{
for (auto& Unit : UnitList)
{
auto Owner = Unit->GetOwnerMeshComponent().lock();
if (!Owner || Owner->IsDeath() || !Owner->IsUpdate())
{
continue;
}
AllRenderUnits.push_back(Unit);
}
}
}
float4 CamPos = GetTransform()->GetWorldPosition();
std::sort(AllRenderUnits.begin(), AllRenderUnits.end(),
[&](const std::shared_ptr<Ext_MeshComponentUnit>& A, const std::shared_ptr<Ext_MeshComponentUnit>& B)
{
auto AMesh = A->GetOwnerMeshComponent().lock();
auto BMesh = B->GetOwnerMeshComponent().lock();
return (AMesh->GetTransform()->GetWorldPosition() - CamPos).Size()
> (BMesh->GetTransform()->GetWorldPosition() - CamPos).Size();
});
std::unordered_set<std::shared_ptr<Ext_MeshComponent>> UpdatedComponents;
for (auto& Unit : AllRenderUnits)
{
auto Owner = Unit->GetOwnerMeshComponent().lock();
if (!Owner) continue;
// View/Projection은 한 번만 업데이트
if (UpdatedComponents.insert(Owner).second)
{
Owner->Rendering(_Deltatime, GetViewMatrix(), GetProjectionMatrix()); // 행렬 업데이트
}
Unit->Rendering(_Deltatime); // 렌더링 파이프라인 세팅 후 드로우콜
}
//////////////////////// 쉐도우 패스(Shadow Buffer) /////////////////////
// 쉐도우 패스, 뎁스 만들기
auto& Lights = GetOwnerScene().lock()->GetLights();
for (auto& [name, CurLight] : Lights)
{
std::shared_ptr<Ext_DirectXRenderTarget> CurShadowRenderTarget = CurLight->GetShadowRenderTarget();
if (!CurShadowRenderTarget) continue; // 세팅 안되어있으면 그릴 필요 없음
std::shared_ptr<LightData> LTData = CurLight->GetLightData(); // 현재 라이트의 데이터(앞서 업데이트됨)
CurShadowRenderTarget->RenderTargetSetting(); // 백버퍼에서 지금 렌더 타겟으로 바인딩 변경, 여기다 그리기
// 쉐도우 뎁스 텍스쳐 만들기
for (auto& Unit : AllRenderUnits)
{
if (!Unit->GetIsShadow()) continue;
Unit->GetOwnerMeshComponent().lock()->GetTransform()->SetCameraMatrix(LTData->LightViewMatrix, LTData->LightProjectionMatrix); // 라이트 기준으로 행렬 세팅
Unit->RenderUnitShadowSetting();
auto PShadow = Ext_DirectXMaterial::Find("Shadow");
PShadow->VertexShaderSetting();
PShadow->RasterizerSetting();
PShadow->PixelShaderSetting();
PShadow->OutputMergerSetting();
Unit->RenderUnitDraw();
}
}
//////////// 라이트 패스(Diffuse, Specluar, Ambient Buffer) /////////////
LightRenderTarget->RenderTargetClear();
LightRenderTarget->RenderTargetSetting();
GetOwnerScene().lock()->GetLightDataBuffer().LightCount = 0; // 라이트 업데이트 전, 상수버퍼 갯수 초기화(순회하면서 넣어줘야하기 때문)
for (auto& [name, CurLight] : Lights)
{
//LightUnit.BufferSetter.SetTexture(CurLight->GetShadowRenderTarget()->GetTexture(0), "ShadowTex");
LightUnit.Rendering(_Deltatime);
GetOwnerScene().lock()->GetLightDataBuffer().LightCount++;
}
// 빛 합치기(Merge)
LightMergeRenderTarget->RenderTargetClear();
LightMergeRenderTarget->RenderTargetSetting();
LightMergeUnit.Rendering(_Deltatime);
////////////////////////////// MRT Merge ///////////////////////////////
CameraRenderTarget->RenderTargetClear();
CameraRenderTarget->Merge(MeshRenderTarget, 0);
CameraRenderTarget->Merge(LightMergeRenderTarget);
}
1. Geometry Pass : Mesh, Position, Normal Buffer 생성
2. Shadow Pass : Shadow Map 생성
3. Light Pass : Diffuse, Specular, Ambient Buffer 생성
3. G-Buffer 생성
: 셰이더를 통해 값을 생성해주고 G-Buffer에 담습니다. 여기서 중요한 변경점은 Output 구조체의 출력값입니다. 원래는 시스템 시멘틱과 attribute를 출력했지만, 이제부터는 SV_TARGET[n]를 출력합니다. 이러면 OMSetRenderTargets() 함수로 바인딩된 RenderTarget의 RTV들에게 출력 결과물이 저장됩니다.
// Static_VS, Dynamic_VS는 기존과 동일
// Graphics_PS
#include "LightData.fx"
Texture2D BaseColorTex : register(t0); // 텍스처 자원
SamplerState Sampler : register(s0); // 샘플러
struct PSInput
{
float4 Position : SV_POSITION;
float2 TexCoord : TEXCOORD;
float3 WorldPosition : POSITION0;
float3 WorldViewPosition : POSITION1;
float3 WorldNormal : NORMAL0;
float3 WorldViewNormal : NORMAL1;
};
struct PSOutPut
{
float4 MeshTarget : SV_TARGET0;
float4 WPositionTarget : SV_TARGET1; // World
float4 WVPositionTarget : SV_TARGET2; // WorldView
float4 WNormalTarget : SV_TARGET3; // World
float4 WVNormalTarget : SV_TARGET4; // WorldView
};
// 각 벡터에 normalize를 해주는 이유는, 명시적으로 normalize된 벡터를 넣어줬다 하더라도
// 임의의 값이 어떻게 들어올지 모르기 때문에 그냥 해주는것(안정성을 위한 처리라고 보면 됨)
PSOutPut Graphics_PS(PSInput _Input) : SV_TARGET
{
PSOutPut Output = (PSOutPut) 0;
Output.MeshTarget = BaseColorTex.Sample(Sampler, _Input.TexCoord); // 텍스쳐컬러
Output.WPositionTarget = float4(_Input.WorldPosition.xyz, 1.0f); // 월드스페이스 Position
Output.WVPositionTarget = float4(_Input.WorldViewPosition.xyz, 1.0f); // 뷰스페이스 Position
Output.WNormalTarget = float4(_Input.WorldNormal, 1.0f); // 월드스페이스 Normal
Output.WVNormalTarget = float4(_Input.WorldViewNormal, 1.0f); // 뷰스페이스 Normal
return Output;
}
위에는 Vertex Shader이고, Light는 Pixel Shader에서 진행해줍니다.
: 위의 셰이더들을 통해 Mesh, Position, Normal, Diffuse, Specular, Ambient, Shadow 값이 Buffer들(RenderTarget)에 담깁니다. 이제 이들을 하나로 합쳐줍니다. 여기서 각 Buffer값(Texture인데 실제로는 SRV)을 합쳐주기 위한 셰이더의 슬롯에 바인딩해줍니다.
BufferSetting을 각 RenderTarget마다 진행할 것이기 때문에, 렌더링 파이프라인 세팅을 위하여 각각의 Unit들을 만들어줬습니다. 여기서 FullRect를 활용하는데, 이러면 화면에 꽉 차는 사각형이 하나 만들어질 것이며, 여기에 결과물들을 그려주게 될 것입니다.
[디퍼드 렌더링을 위해 필요한 기능들]
1. 셰이더 리소스 뷰(SRV)
: 원래도 Texture를 활용할 때 사용하고 있었는데, RenderTarget을 텍스쳐 슬롯에 바인딩 할때도 똑같이 해당 기능을 활용하게 됩니다.
// 텍스쳐를 사용한 경우, 여기서 추가로 VSSetting 실시
void Ext_DirectXTexture::VSSetting(UINT _Slot)
{
if (nullptr == SRV)
{
MsgAssert("SRV가 존재하지 않는 텍스처를 쉐이더에 세팅할수 없습니다.");
return;
}
Ext_DirectXDevice::GetContext()->VSSetShaderResources(_Slot, 1, &SRV);
}
// 텍스쳐를 사용한 경우, 여기서 추가로 PSSetting 실시
void Ext_DirectXTexture::PSSetting(UINT _Slot)
{
if (nullptr == SRV)
{
MsgAssert("SRV가 존재하지 않는 텍스처를 쉐이더에 세팅할수 없습니다.");
return;
}
Ext_DirectXDevice::GetContext()->PSSetShaderResources(_Slot, 1, &SRV);
}
SRV에 대해서 다시 짚고 넘어가자면, 그림판을 어떻게 다룰지에 대한 설명서라고 보시면 됩니다. 어떤 Slot 정보를 가져야 하는지, Format은 뭔지, 몇 차원(Dimentions) 그림인지, MipMap 수준은 어떻게 되는지, 데이터 Array는 어떤지를 SRV가 가지고 있습니다.
SRV 바인딩은 주로 Pixel Shader에서 실시하기 떄문에, PSSetShaderResources() 함수를 호출하여 여기에 텍스쳐의 SRV를 바인딩해주면 현재 바인딩된 Pixel Shader의 t 슬롯에 바인딩 됩니다. Multi Render Target의 경우에는 한꺼번에 넘겨줘서 바인딩시킬 수 있긴 한데, 해당 프레임워크는 그냥 반복문을 돌면서 하나씩 바인딩 해주는 걸로 진행했습니다.
2. Multi Render Target(MRT)
: 말 그대로 여러 개의 렌더 타겟인데, 위에서 설명한 것과 같이 출력 결과를 RenderTarget 여러 개로 지정할 수 있습니다.
: 기존의 패스(Geometry Pass)에서는 깊이 버퍼에 기록된 것보다 뒤에 존재하는 픽셀은 가린다는 의미로 LESS_EQUAL을 사용했지만, Lighting 패스에서는 전체 화면(FullRect)에 대해 조명 값을 누적할 때,
- 이미 기존 패스로 Depth만큼 채워진 픽셀에 대해서만 조명을 적용
- 실제로 깊이 비교(EQUAL) 대신 그냥 모든 스크린 픽셀에 대해서만 조명 계산을 하는게 단순
- 정밀도 차이로 픽셀이 빠져나가는 현상을 방지
를 위해서 깊이 테스트를 우회(ALWAYS)하고자 AlwaysDepth로 설정해줍니다. 이러면 조명 대상 컬링은 스텐실이나 마스킹으로 처리하게 됩니다.
- Blend : 빛 기여를 정확히 덧셈하여 누적
: 기존의 패스는 투명도 기반 혼합 방식(Src =SRC_ALPHA, Dest =INV_SRC_ALPHA)을 썼지만, Lighting 패스는 덧셈 방식(Additive)으로 값을 누적해야 합니다. Diffuse, Specular, Ambient는 서로 그냥 더해야 최종 밝기가 나오는데, 여기서 만약 기존의 블렌딩 방식을 써버리면 투명도 기반으로만 섞이기 때문에 조명에 관계없는 알파값이 섞여버려 오작동이 발생하거나 부자연스러운 결과가 발생할 수 있습니다. 따라서 Src와 Dest를 ONE, Op를 ADD로 설정하여 빛의 세기만 더하도록 변경해줍니다.
- Rasterizer : 전체 화면/라이트 볼륨 모든 면에 대해 조명을 적용
: Light 패스는 FullRect로 화면을 덮어서 활용하는데, 라이트 매스를 활용할 수도 있긴 합니다. 이럴 경우에는 만약 후면 컬링이 켜져 있으면 뷰 방향에 따라서 일부 폴리곤(뒷면)이 잘려나가 제대로 조명이 적용되지 안흔ㄴ 영역이 발생할 수 있습니다. 따라서 아무 면도 걸러내지 않도록 Cull 모드를 NONE으로 설정해줍니다. 사실 FullRect로 화면 전체를 덮어버린다면 그냥 원래 것을 사용해도 무방하긴 합니다.
[4], [5] LightMergeRenderTarget->
: 1부터 3번까지 진행하면서 Diffuse, Specular, Ambient 라이트 버퍼를 생성했기 때문에, 이제 이 결과들을 하나로 합쳐줘야 합니다. 이를 위해서 하나로 합쳐줄 렌더 타겟으로 바인딩해줍니다. 여기서는 빛을 합치는 것 외에 다양한 처리를 진행할 수 있습니다(나중에 쉐도우도 활용할 예정)
[6] Rendering
: [3]에서 실시한 Rendering 과정과 다른게 딱 하나 입니다. 여기서는 Texture의 Image 값들에 대해서 합치기 때문에, Blend가 다시 원래의 AlphaBlend(BaseBlend)로 해주면 됩니다.
1번부터 6번까지 진행했으면, Mesh, Light 값이 저장되어 있을 것입니다. 이걸 하나의 렌더 타겟에 다시 Merge 해줍니다. 뭔가 Merge라는 함수를 만들어서 특별하게 처리하는것 같지만, 실상은 위의 LightMergeRenderTarget에서 Merge 처리했던 방식과 크게 다른게 없습니다. 그냥 다른 렌더 타겟의 Texture2D Image 값을 가져와서 현재의 RenderTarget에 Draw 하는 것입니다. 순서대로 Draw 콜 해주면, 최종 결과물이 나옵니다.
포워드 렌더링은 전통적인 렌더링 방식으로, 라이트 처리를 오브젝트마다 실시하는 방식입니다.
10개의 오브젝트와 10개의 라이트가 있다고 가정해보겠습니다. 이러면 포워드 렌더링의 경우, 각 오브젝트마다 라이팅 연산을 10번씩 실시해주면 됩니다(10*10 = 100). 비교적 단순하게 구현할 수 있는 렌더링 방식이지만, 치명적인 문제가 바로 단순함에 있습니다.
방금 말씀드린것 처럼 10개의 오브젝트와 10개의 빛이 있을 때 100번의 연산을 실시한다고 했는데, 만약 100개씩, 1000개씩 있으면 어떻게 될까요? 1000 * 1000번의 연산을 실시하게 될 것입니다. 또한, 물체에 가려져 화면에 보이지 않는 오브젝트에 대해서도 컬링을 하지 않고 빛 연산을 실시하는 경우가 있기도 합니다.
단점만 말씀드려서 이걸 왜쓰지라는 생각이 드실 수도 있으실테지만, 포워드 렌더링도 장점이 있습니다.
1. 메모리 대역폭
: 이후 말씀드릴 디퍼드 렌더링의 경우에는 G-Buffer라는 것을 사용하기 때문에 다수의 렌더 타겟을 필요로 합니다. 렌더 타겟은 모두 텍스쳐라고 생각하시면 편한데, 렌더 타겟의 크기는 픽셀을 표현할 크기와 픽셀 갯수로 정해집니다. 예를들어 R32G32B32A32로 픽셀을 표현하고 해상도의 크기가 1920 * 1080라고 가정해봅시다. 단순하게 계산해도 하나의 렌더 타겟에 약 2억6천만bit(약 33MB)가 됩니다.
하지만 이게 하나의 렌더 타겟만 있는게 아니라 다수의 렌더 타겟(MRT)을 사용하는 방식이기 때문에 실제로는 33MB보다 훨씬 큽니다. 이걸 프레임별로 계속해서 처리해야하는데, 메모리 대역폭이 제한적인 모바일 환경에서는 감당하기 힘든 수준이 됩니다. 때문에 비교적 효율적이고 전통적인 포워드 렌더링을 활용합니다(물론 그냥 포워드 렌더링이 아니라 포워드+ 렌더링을 사용할 것입니다).
2. 하드웨어 제한
: 메모리 대역폭의 제한 때문도 있지만, 하드웨어 자체의 한계로 인해 포워드 렌더링을 사용하는 것이 좋은 경우도 있습니다. 모바일 환경과 같은 경우 GPU가 MRT를 4개 이상 지원하지 않거나, 지원하더라도 성능 저하가 발생할 우려가 있어 G-Buffer의 구축이 어렵기 때문에 포워드 렌더링을 활용하는 경우가 있습니다.
3. 라이트 수와 복잡도
: Scene에 라이트가 디렉셔널, 포인트 라이트 두 개만 있다고 가정해봅시다. 이럴 경우, 굳이 G-Buffer를 쓰면서까지 라이팅 처리를 해야할까요? 라이트가 제한된 환경에서는 포워드 렌더링이 구현 복잡도도 낮고, 메모리 낭비를 하지 않을 수 있기 때문에 디퍼드 렌더링보다 더 효율적 일 수 있습니다.
[포워드+ 렌더링]
그렇다면 포워드 렌더링 방식에서 빛 연산 횟수를 줄이면 좋을 것입니다. 이를 위해 포워드+ 렌더링이 생겨났습니다. 해당 렌더링 방식은 기존과 같이 연산을 실시하는 과정 앞에 조명 컬링을 위한 렌더링 패스를 한 번 진행하게 됩니다.
이 과정에서 화면에 그려지는 공간을 균등한 타일 그리드로 나누고, 정보(Light Heatmap)를 저장해둡니다.
https://www.3dgep.com/forward-plus/
다음 패스에서 일반적인 포워드 렌더링 패스를 진행하는데, 앞에서 저장한 정보를 활용합니다. 이 정보를 활용하여 조명 처리를 진행할 오브젝트에 대해서만 조명 처리를 실시하게 됩니다.
물론 그리드를 어떻게 나누는지에 따라(많이 나눌 것이냐, 적게 나눌 것이냐) Light Heatmap의 정보 처리가 Light 처리보다 커질 수도 있고(배보다 배꼽이 커지는 경우), 불필요한 연산을 제대로 컬링하지 못하는 수도 있습니다.
[디퍼드 렌더링]
기존의 포워드 렌더링이 오브젝트 하나에 대해 빛 연산을 실시하고 드로우콜을 호출하여 오브젝트를 화면에 바로바로 그리는 방식이었다면, 디퍼드 렌더링은 말 그대로 렌더링을 지연시키는 것입니다. 가장 큰 이유는 바로 빛 연산을 한번에 처리해서 적용시키기 위함입니다.
디퍼드 렌더링은 여러 개의 렌더 타겟(멀티 렌더 타겟;MRT)을 버퍼(G-Buffer)로 활용하면서 서로 간에 블렌딩을 통해 하나의 렌더 타겟을 만들어 냅니다.
디퍼드 렌더링의 빛 연산 과정은 다음과 같습니다.
1. 각 오브젝트들의 Position, Normal 정보를 담은 Buffer를 만듦
2. Position, Normal Buffer를 통해 Diffuse, Specular, Ambient 등 라이팅 연산 실시 및 Buffer를 만듦
3. 라이트 버퍼와 메시가 렌더링된 렌더 타겟 간에 블렌딩 과정을 통해 최종 렌더링 결과물 출력 및 백 버퍼에 전달
위 방식대로 진행하면, 화면에 몇 개의 오브젝트가 존재하던지 간에 라이트의 갯수와 화면의 픽셀 갯수 만큼만 연산을 실시하게 됩니다. 덕분에 오브젝트와 광원 수가 늘어나더라도, 포워드 렌더링과 같이 기하급수적으로 라이팅 처리 비용이 늘어나지는 않습니다.
그대로 장점만 있는 것은 아닙니다. 앞서 포워드 렌더링의 장점이 곧 디퍼드 렌더링의 단점이라고 볼 수 있겠습니다.
1. G-Buffer를 위한 메모리 대역폭 확보 필요
: MRT를 활용하기 때문에 이들을 저장하고 활용하기 위한 공간 확보는 필수입니다. 이 때문에 기본적으로 디퍼드 렌더링을 위해서는 메모리 대역폭의 확보가 필요합니다.
2. MRT 지원 하드웨어 필요
: GPU가 MRT를 지원하지 않거나, 지원하더라고 수의 제한이 있거나, 사용 시에 성능이 현저히 떨어질 수 있습니다. 디퍼드 렌더링을 사용하더라도 하드웨어의 성능이 받쳐줘야합니다.
3. 라이트 수에 대한 딜레마
: 포워드 렌더링은 메모리를 덜 쓰고 연산을 많이 하는 방식이었다면, 디퍼드 렌더링은 메모리를 많이 써서 연산을 덜 하는 방식을 취합니다. 하지만 배보다 배꼽이 커지는 경우가 있을 수 있는데, Scene에 라이트가 단 하나만 존재한다면 어떻게 될까요? 이럴 때는 그냥 라이트 하나에 대해서만 라이팅 연산을 진행하고, 구현이 쉬운 포워드 렌더링을 그대로 쓰는 것이 좋을 것입니다. 하지만 굳이 디퍼드 렌더링을 활용하게 되면, 필수적으로 G-Buffer를 만들 것이고, 이를 활용해야하기 때문에 최적화를 위해 만들어진 디퍼드 렌더링 방식이 오히려 독이 될 수 있습니다.
이렇게 해주면 BaseColorTex 슬롯에는 [StoneWall_BaseColor.jpg]이름의 2DTexture가, NormalTex 슬롯에는 [StoneWall_Normal.jpg]이름의 2DTexture가 저장될 것입니다. Material은 PBR이라고 해서 기존의 Graphics_VS, PS를 그대로 복사한 것을 새로 하나 만들어줬습니다. 기존껀 두고 이걸 수정하려고 합니다.
이제 불러온 값을 활용해야합니다. 해당 텍스쳐는 "탄젠트 스페이스 노말맵"입니다. 처음에 노말 매핑에 대해 공부할 때는, 뭐가 월드고 뭐가 탄젠트고 헷갈렸는데, 그림 하나를 보니 바로 구분이 가능해졌습니다.
그리고 근래에는 거의 무조건 스태틱이든 다이나믹이든 탄젠트 스페이스 노말맵을 사용한다고 합니다. 하드웨어 성능이 좋아져서 픽셀 셰이더에서 TBN 행렬을 적용하는 과정이 큰 무리가 없나봅니다.
이제 정점 셰이더를 수정해줍니다. Input값이 기존과 다르게 변경되었는데, 바로 float4 Tangent : TANGENT, float4 Binormal : BINORMAL가 추가됐다는 점입니다.