다중 카메라 렌더링 시스템 설계와 구현
현대 게임 엔진에서 다중 카메라 렌더링은 필수적인 기능입니다. 에디터 뷰와 게임 뷰의 분리, UI 오버레이, 미니맵, 분할 화면 멀티플레이 등 다양한 용도로 활용됩니다. 이 문서에서는 DirectX 11을 사용하여 효율적인 다중 카메라 시스템을 구현하는 방법을 상세히 다룹니다.
다중 카메라가 필요한 이유
단일 카메라만으로는 구현할 수 없는 다양한 게임 기능과 에디터 기능이 존재합니다. 각 사용 사례를 구체적으로 살펴보겠습니다.
게임 뷰와 에디터 뷰 분리
Unity나 Unreal Engine과 같은 상용 엔진의 에디터에서는 씬 뷰와 게임 뷰를 동시에 표시합니다. 이는 두 개의 독립적인 카메라로 구현됩니다.
•
Game View Camera: 실제 게임에서 플레이어가 보는 시점을 렌더링합니다. 이 카메라는 게임 로직에 의해 제어되며, 플레이어 캐릭터를 따라다니거나 특정 이벤트에 반응합니다. Perspective 투영을 사용하여 원근감을 제공하고, 플레이어의 몰입감을 극대화합니다.
•
Scene View Camera: 개발자가 씬을 자유롭게 탐색할 수 있는 에디터 전용 카메라입니다. WASD 이동, 마우스 회전, 휠 줌 등 에디터 내비게이션 기능을 제공하며, 기즈모(Gizmo), 그리드, 와이어프레임 등 개발 도구를 함께 렌더링합니다.
이 두 카메라는 서로 다른 렌더 타겟에 그려지거나, 뷰포트를 분할하여 동시에 화면에 표시됩니다. 개발자는 게임이 실제로 어떻게 보이는지 확인하면서 동시에 씬을 편집할 수 있습니다.
UI와 3D 월드 분리
3D 게임의 UI는 월드 공간과 독립적으로 렌더링되어야 합니다. UI는 깊이 테스트, 조명, 안개, 그림자 등의 영향을 받지 않고 항상 화면 최전면에 표시되어야 하기 때문입니다.
UI 카메라는 일반적으로 다음과 같은 설정을 사용합니다.
•
Orthographic 투영: 원근감 없이 2D 평면으로 렌더링하여 UI 요소의 크기가 일정하게 유지됩니다.
•
깊이 쓰기 비활성화: UI 요소 간 Z-ordering만 사용하여 3D 월드의 깊이 버퍼와 독립적으로 작동합니다.
•
별도의 렌더 패스: 월드 렌더링이 완전히 끝난 후 마지막에 실행되어 모든 것 위에 그려집니다.
미니맵, CCTV, 거울, 보안카메라
게임 내에서 다른 위치나 시점을 보여주는 다양한 기능들이 있습니다.
미니맵
플레이어 위치 상공에서 하향으로 보는 탑다운 카메라입니다. 작은 해상도의 렌더 타겟에 씬을 그린 후 UI의 일부로 표시합니다.
•
카메라 위치: 플레이어 위 높은 곳
•
투영 방식: Orthographic (탑다운 뷰)
•
렌더 타겟: 256x256 또는 512x512 텍스처
•
렌더링 레이어: 지형, 적, 아군만 표시 (세부 오브젝트 제외)
CCTV/보안카메라
고정된 위치에서 특정 각도로 씬을 렌더링합니다. 렌더 텍스처를 생성하여 게임 내 모니터 오브젝트의 재질에 적용합니다.
•
카메라 위치: 월드 공간의 고정된 지점
•
효과: 노이즈, 스캔라인, 색상 왜곡 등 CCTV 특유의 효과
•
업데이트 빈도: 성능을 위해 매 프레임이 아닌 일정 간격
거울 반사
플레이어의 반대편 시점에서 렌더링하여 평면 반사 효과를 구현합니다.
•
카메라 위치: 거울 평면 기준으로 대칭 변환
•
컬링 평면: 거울 평면으로 설정하여 뒤쪽 오브젝트 제거
•
렌더 타겟: 거울 해상도에 맞는 텍스처
•
최적화: 반사 품질 레벨에 따라 해상도와 렌더링 거리 조절
분할 화면 멀티플레이
로컬 멀티플레이 게임에서 각 플레이어는 독립적인 시점이 필요합니다.
각 카메라는 동일한 씬을 렌더링하지만 뷰포트와 시점이 다릅니다.
•
렌더 타겟: 모든 카메라가 동일한 백버퍼 공유
•
뷰포트: RSSetViewports()로 화면을 분할
•
시저 렉트: 각 플레이어 영역을 명확히 구분
•
독립적인 컬링: 각 카메라의 절두체에 따라 개별 컬링 수행
후처리 효과 분리
서로 다른 카메라에 서로 다른 포스트 프로세싱을 적용할 수 있습니다.
메인 카메라
•
Bloom: 밝은 영역에서 빛 번짐 효과
•
Depth of Field: 초점 거리에 따른 흐림 효과
•
Motion Blur: 빠른 움직임의 잔상
•
Color Grading: 영화적 색감 조정
•
SSAO: 화면 공간 ambient occlusion
미니맵 카메라
•
후처리 효과 없음
•
깔끔하고 명확한 정보 표시
•
성능 최적화를 위해 단순화
UI 카메라
•
Glow: UI 요소 강조
•
선택적 블러: 배경 흐리기
•
색상 보정: UI 가독성 향상
렌더 타겟 별도 관리
각 카메라가 서로 다른 렌더 타겟에 렌더링할 수 있습니다.
Game View Camera
•
타겟: 메인 백버퍼 또는 전용 렌더 텍스처
•
해상도: 윈도우 크기 또는 설정된 게임 해상도
•
포맷: R8G8B8A8_UNORM (일반), R16G16B16A16_FLOAT (HDR)
Scene View Camera
•
타겟: 에디터 패널용 텍스처
•
해상도: 에디터 뷰 크기에 따라 동적 변경
•
추가 정보: 선택 아웃라인, 기즈모, 그리드 오버레이
Shadow Map Camera
•
타겟: 깊이 전용 텍스처
•
포맷: D32_FLOAT 또는 D24_UNORM_S8_UINT
•
다중 캐스케이드: 거리별로 여러 해상도의 섀도우 맵
Reflection Probe Camera
•
타겟: 큐브맵 (6면) 또는 평면 텍스처
•
업데이트: 정적 환경은 한 번, 동적 환경은 주기적
다중 카메라 렌더링 파이프라인
전체 렌더링 루프에서 카메라별로 씬을 순회하며 오브젝트를 렌더링하는 구조를 구현합니다.
메인 렌더링 루프
void Scene::Render()
{
for (Camera* camera : mCameras)
{
if (camera == nullptr)
continue;
// 카메라 변환 행렬과 위치 획득
Matrix viewMatrix = camera->GetViewMatrix();
Matrix projectionMatrix = camera->GetProjectionMatrix();
Vector3 cameraPos = camera->GetOwner()->GetComponent<Transform>()->GetPosition();
// 렌더링 리스트 초기화
std::vector<GameObject*> opaqueList = {};
std::vector<GameObject*> cutoutList = {};
std::vector<GameObject*> transparentList = {};
// 렌더링 가능한 오브젝트 수집 및 분류
CollectRenderables(opaqueList, cutoutList, transparentList);
// 카메라로부터의 거리로 정렬
SortByDistance(opaqueList, cameraPos, true); // 가까운 것부터
SortByDistance(cutoutList, cameraPos, true); // 가까운 것부터
SortByDistance(transparentList, cameraPos, false); // 먼 것부터
// 분류된 오브젝트 렌더링
RenderRenderables(opaqueList, viewMatrix, projectionMatrix);
RenderRenderables(cutoutList, viewMatrix, projectionMatrix);
RenderRenderables(transparentList, viewMatrix, projectionMatrix);
}
}
C++
복사
이 구조의 핵심 장점은 다음과 같습니다.
카메라별 독립적인 처리
•
각 카메라마다 별도의 컬링과 정렬 수행
•
카메라 설정에 따라 다른 렌더 상태 적용
•
렌더 타겟과 뷰포트를 카메라별로 전환
렌더링 효율성
•
재질 타입별로 오브젝트를 그룹화하여 상태 변경 최소화
•
거리 기반 정렬로 깊이 테스트 효율 극대화
•
투명 오브젝트의 올바른 블렌딩 보장
뷰와 투영 행렬
각 카메라는 고유한 뷰 행렬과 투영 행렬을 가지며, 이는 3D 변환 파이프라인의 핵심입니다.
Matrix viewMatrix = camera->GetViewMatrix();
Matrix projectionMatrix = camera->GetProjectionMatrix();
Vector3 cameraPos = camera->GetOwner()->GetComponent<Transform>()->GetPosition();
C++
복사
뷰 행렬 (View Matrix)
월드 공간의 좌표를 카메라 공간(뷰 공간)으로 변환합니다. 카메라의 위치와 방향을 기반으로 생성됩니다.
투영 행렬 (Projection Matrix)
카메라 공간(뷰 공간)의 좌표를 정규화된 장치 좌표(NDC, Normalized Device Coordinates)로 변환합니다.
Perspective 투영 (원근 투영)
Matrix perspectiveMatrix = Matrix::CreatePerspectiveFieldOfView(
fov, // 시야각 (일반적으로 60~90도)
aspectRatio, // 화면 비율 (width / height)
nearPlane, // 근거리 클립 평면 (예: 0.1f)
farPlane // 원거리 클립 평면 (예: 1000.0f)
);
C++
복사
Orthographic 투영 (직교 투영)
Matrix orthographicMatrix = Matrix::CreateOrthographic(
width, // 뷰 너비
height, // 뷰 높이
nearPlane,
farPlane
);
C++
복사
이 두 행렬은 상수 버퍼를 통해 정점 셰이더로 전달되어 정점 변환에 사용됩니다.
렌더 리스트 분류
오브젝트를 재질의 렌더링 모드에 따라 세 가지 리스트로 분류합니다. 이는 올바른 렌더링 순서와 상태 설정을 위해 필수적입니다.
Opaque (불투명)
완전히 불투명한 오브젝트로, 깊이 버퍼를 적극적으로 활용합니다.
•
깊이 테스트: 활성화 (가까운 픽셀만 렌더링)
•
깊이 쓰기: 활성화 (다른 오브젝트의 깊이 비교 기준)
•
블렌딩: 비활성화 (픽셀을 완전히 덮어씀)
•
정렬: 앞에서 뒤로 (Early-Z 최적화)
•
사용 사례: 벽, 바닥, 캐릭터, 대부분의 고체 오브젝트
Cutout (알파 테스트)
투명 영역과 불투명 영역이 명확히 구분되는 오브젝트입니다.
•
깊이 테스트: 활성화
•
깊이 쓰기: 활성화 (불투명 픽셀만)
•
블렌딩: 비활성화
•
알파 테스트: 픽셀 셰이더에서 discard 사용
•
정렬: 앞에서 뒤로
•
사용 사례: 나뭇잎, 풀, 철망, 구멍 난 천
Transparent (투명)
부드러운 투명 효과를 가진 오브젝트입니다.
•
깊이 테스트: 설정 가능 (보통 활성화)
•
깊이 쓰기: 비활성화 (뒤쪽 투명 오브젝트도 보이도록)
•
블렌딩: 활성화 (알파 블렌딩)
•
정렬: 뒤에서 앞으로 (Painter's Algorithm)
•
사용 사례: 유리, 물, 연기, 파티클, 반투명 UI
렌더링 가능 오브젝트 수집
씬의 모든 레이어를 순회하며 렌더링할 오브젝트를 수집하고 재질 타입별로 분류합니다.
void Scene::CollectRenderables(
std::vector<GameObject*>& opaqueList,
std::vector<GameObject*>& cutoutList,
std::vector<GameObject*>& transparentList) const
{
for (Layer* layer : mLayers)
{
if (layer == nullptr)
continue;
std::vector<GameObject*>& gameObjects = layer->GetGameObjects();
for (GameObject* gameObj : gameObjects)
{
if (gameObj == nullptr)
continue;
// 향후 BaseRenderer로 추상화 권장
SpriteRenderer* renderer = gameObj->GetComponent<SpriteRenderer>();
if (renderer == nullptr)
continue;
// 재질의 렌더링 모드에 따라 분류
switch (renderer->GetMaterial()->GetRenderingMode())
{
case graphics::eRenderingMode::Opaque:
opaqueList.push_back(gameObj);
break;
case graphics::eRenderingMode::CutOut:
cutoutList.push_back(gameObj);
break;
case graphics::eRenderingMode::Transparent:
transparentList.push_back(gameObj);
break;
}
}
}
}
C++
복사
렌더링 모드별 상세 설명
Opaque (불투명) 렌더링
불투명 오브젝트는 게임 씬의 대부분을 차지하며, 가장 효율적으로 렌더링됩니다.
동작 원리
•
깊이 버퍼에 기록된 값과 새 픽셀의 깊이를 비교
•
더 가까운 픽셀만 렌더링하고 깊이 버퍼 업데이트
•
뒤쪽 픽셀의 프래그먼트 셰이더는 실행되지 않음 (Early-Z)
최적화 전략
•
앞에서 뒤로 정렬하여 Early-Z 최적화 극대화
•
동일한 재질을 연속으로 렌더링하여 상태 변경 최소화
•
큰 오브젝트를 먼저 그려 뒤쪽 오브젝트의 픽셀 제거
Cutout (알파 테스트) 렌더링
알파 테스트는 픽셀을 완전히 투명하게 만들거나 완전히 불투명하게 만드는 이진 선택입니다.
픽셀 셰이더 구현
사용 사례
•
나뭇잎: 잎의 외곽선이 명확히 구분됨
•
철망: 구멍이 뚫린 금속 펜스
•
구멍 난 천: 찢어진 깃발이나 낡은 옷
•
글리프: 텍스트 렌더링의 글자 외곽선
장점
•
깊이 버퍼를 정상적으로 사용하여 성능 양호
•
투명 오브젝트처럼 정렬할 필요 없음
•
구현이 단순함
단점
•
하드 에지가 생겨 부자연스러울 수 있음
•
안티앨리어싱이 제대로 적용되지 않음
•
반투명 효과 불가능
Transparent (투명) 렌더링
알파 블렌딩을 사용하여 부드러운 투명 효과를 구현합니다.
블렌딩 공식
깊이 설정이 중요한 이유
•
깊이 쓰기 활성화 시: 첫 번째 투명 오브젝트가 깊이를 기록하면, 뒤쪽의 다른 투명 오브젝트가 깊이 테스트에서 실패하여 렌더링되지 않습니다. 결과적으로 투명 오브젝트 간 블렌딩이 깨집니다.
•
깊이 쓰기 비활성화 시: 모든 투명 픽셀이 렌더링되며, 뒤에서 앞으로 정렬하면 올바른 블렌딩이 적용됩니다.
정렬의 중요성
투명 오브젝트는 반드시 뒤에서 앞으로 정렬해야 합니다 (Painter's Algorithm).
제약 사항
•
정렬 기준은 오브젝트의 중심점이므로, 큰 오브젝트나 복잡한 형태는 정렬 오류 발생 가능
•
교차하는 투명 면은 올바르게 렌더링 불가능
•
Order-Independent Transparency (OIT) 기법으로 해결 가능하지만 복잡함
거리 기반 정렬
카메라로부터의 거리를 기준으로 오브젝트를 정렬하여 올바른 렌더링 순서를 보장합니다.
void Scene::SortByDistance(
std::vector<GameObject*>& renderList,
const Vector3& cameraPos,
bool bAscending) const
{
// 람다 함수로 정렬 기준 정의
auto comparator = [cameraPos, bAscending](GameObject* a, GameObject* b)
{
// 각 오브젝트와 카메라 간 거리 계산
float distA = Vector3::Distance(
a->GetComponent<Transform>()->GetPosition(),
cameraPos
);
float distB = Vector3::Distance(
b->GetComponent<Transform>()->GetPosition(),
cameraPos
);
// 오름차순/내림차순 선택
return bAscending ? (distA < distB) : (distA > distB);
};
// C++20 ranges::sort 사용
std::ranges::sort(renderList, comparator);
}
C++
복사
정렬 전략
불투명 및 Cutout 오브젝트: 오름차순 (가까운 것부터)
Early-Z 최적화를 활용하기 위해 가까운 오브젝트를 먼저 렌더링합니다.
Early-Z 최적화 원리
성능 이득
•
복잡한 픽셀 셰이더 (라이팅, 텍스처 샘플링)를 실행하지 않음
•
오버드로우(Overdraw) 감소
•
프레임레이트 향상, 특히 복잡한 씬에서 효과적
투명 오브젝트: 내림차순 (먼 것부터)
Painter's Algorithm에 따라 먼 오브젝트를 먼저 그려야 블렌딩이 올바르게 작동합니다.
Painter's Algorithm
정렬 최적화
매 프레임 전체 리스트를 정렬하는 것은 비용이 높을 수 있습니다. 다음과 같은 최적화 기법을 고려할 수 있습니다.
정적 오브젝트 캐싱
// 정적 오브젝트는 한 번만 정렬
static bool isStaticSorted = false;
if (!isStaticSorted && allObjectsStatic)
{
std::ranges::sort(staticOpaqueList, comparator);
isStaticSorted = true;
}
C++
복사
컬링과 정렬 통합
// 시야 절두체 내 오브젝트만 수집하고 정렬
std::vector<GameObject*> visibleObjects;
for (auto* obj : allObjects)
{
if (camera->IsInFrustum(obj->GetBounds()))
{
visibleObjects.push_back(obj);
}
}
std::ranges::sort(visibleObjects, comparator);
C++
복사
버킷 정렬
// 거리를 구간으로 나누어 대략적인 정렬
const int BUCKET_COUNT = 10;
std::vector<GameObject*> buckets[BUCKET_COUNT];
for (auto* obj : objects)
{
float dist = Vector3::Distance(obj->GetPosition(), cameraPos);
int bucketIndex = static_cast<int>(dist / maxDistance * BUCKET_COUNT);
buckets[bucketIndex].push_back(obj);
}
// 각 버킷 내에서만 세밀한 정렬
C++
복사
깊이 버퍼와 깊이 테스트
깊이 버퍼(Depth Buffer 또는 Z-Buffer)는 각 픽셀의 깊이 값을 저장하여 가시성을 판단하는 핵심 메커니즘입니다.
깊이 버퍼의 역할
깊이 버퍼는 렌더 타겟과 동일한 해상도를 가지는 별도의 텍스처로, 각 픽셀의 깊이 값을 0.0(가까움)에서 1.0(멀음) 범위로 저장합니다.
깊이 테스트 기본 동작
깊이 테스트 함수
DirectX는 다양한 깊이 비교 함수를 제공하여 유연한 렌더링이 가능합니다.
함수 | 조건 | 사용 사례 |
Never | 항상 실패 | 렌더링 완전 비활성화, 테스트용 |
Less | newDepth < existingDepth | 일반적인 기본값, 가까운 것만 렌더링 |
Equal | newDepth == existingDepth | 스텐실 마스킹, 특정 깊이만 렌더링 |
LessEqual | newDepth <= existingDepth | 다중 패스 렌더링, 동일 깊이 허용 |
Greater | newDepth > existingDepth | 역방향 깊이 버퍼 (정밀도 향상) |
NotEqual | newDepth != existingDepth | 외곽선 렌더링, 에지 감지 |
GreaterEqual | newDepth >= existingDepth | 역방향 깊이 버퍼와 함께 |
Always | 항상 성공 | 투명 오브젝트, UI, 스카이박스 |
렌더링 모드별 깊이 스텐실 상태
// LessEqual: 일반 불투명 렌더링
D3D11_DEPTH_STENCIL_DESC dsDesc = {};
dsDesc.DepthEnable = true; // 깊이 테스트 활성화
dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL; // 깊이 쓰기 활성화
dsDesc.DepthFunc = D3D11_COMPARISON_LESS_EQUAL; // 가깝거나 같으면 통과
dsDesc.StencilEnable = false;
GetDevice()->CreateDepthStencilState(
&dsDesc,
depthStencilStates[static_cast<UINT>(eDepthStencilState::LessEqual)].GetAddressOf()
);
// DepthNone: 깊이 테스트 완전 비활성화
dsDesc = {};
dsDesc.DepthEnable = false; // 깊이 테스트 비활성화
dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ZERO; // 깊이 쓰기 비활성화
dsDesc.DepthFunc = D3D11_COMPARISON_NEVER;
dsDesc.StencilEnable = false;
GetDevice()->CreateDepthStencilState(
&dsDesc,
depthStencilStates[static_cast<UINT>(eDepthStencilState::DepthNone)].GetAddressOf()
);
// Always: 투명 오브젝트용 (깊이 읽기만, 쓰기 없음)
dsDesc = {};
dsDesc.DepthEnable = true; // 깊이 테스트 활성화 (읽기만)
dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ZERO; // 깊이 쓰기 비활성화
dsDesc.DepthFunc = D3D11_COMPARISON_ALWAYS; // 항상 통과
dsDesc.StencilEnable = false;
GetDevice()->CreateDepthStencilState(
&dsDesc,
depthStencilStates[static_cast<UINT>(eDepthStencilState::Always)].GetAddressOf()
);
C++
복사
Opaque와 Cutout의 깊이 설정
불투명 오브젝트는 깊이 버퍼를 완전히 활용하여 최상의 성능을 제공합니다.
설정값
•
DepthEnable = true: 깊이 테스트 활성화
•
DepthWriteMask = ALL: 깊이 값 쓰기 활성화
•
DepthFunc = LessEqual: 더 가깝거나 같으면 통과
동작 원리
1.
새 픽셀을 렌더링하기 전에 깊이 버퍼 값 확인
2.
새 픽셀이 더 가까우면 색상 버퍼와 깊이 버퍼 모두 업데이트
3.
새 픽셀이 더 멀면 픽셀 셰이더 실행 없이 폐기 (Early-Z)
성능 이득
•
가려진 픽셀의 비싼 셰이더 연산 제거
•
텍스처 샘플링, 라이팅 계산 건너뜀
•
메모리 대역폭 절약
Transparent의 깊이 설정
투명 오브젝트는 깊이 쓰기를 비활성화해야 올바른 블렌딩이 가능합니다.
설정값
•
DepthEnable = true (선택적): 불투명 오브젝트 뒤에 숨기기
•
DepthWriteMask = ZERO: 깊이 값 쓰기 비활성화
•
DepthFunc = Always 또는 LessEqual: 항상 통과 또는 조건부
깊이 쓰기를 비활성화하는 이유
깊이 쓰기가 활성화되면 다음과 같은 문제가 발생합니다.
올바른 설정으로 해결:
깊이 읽기 vs 깊이 쓰기
투명 오브젝트도 불투명 오브젝트 뒤에는 숨겨져야 하므로, 깊이 읽기는 활성화하되 깊이 쓰기는 비활성화합니다.
// 깊이 읽기 O, 깊이 쓰기 X
dsDesc.DepthEnable = true; // 불투명 오브젝트와 비교
dsDesc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ZERO; // 깊이 버퍼 수정 안 함
dsDesc.DepthFunc = D3D11_COMPARISON_LESS_EQUAL; // 더 가까운 것만
C++
복사
효과
•
투명 유리가 벽 뒤에 있으면 가려짐 (깊이 읽기)
•
여러 투명 오브젝트가 겹쳐도 모두 보임 (깊이 쓰기 비활성화)
설정 요약
설정 항목 | Opaque | Transparent | 이유 |
DepthFunc | LessEqual | Always 또는 LessEqual | Opaque는 가까운 것만, Transparent는 모두 또는 조건부 |
DepthWrite | On | Off | Opaque는 기록, Transparent는 읽기만 |
정렬 필요? | 선택적 (성능) | 필수 (정확성) | Transparent는 정렬 없이는 블렌딩 오류 |
목적 | 깊이 테스트로 오버드로우 방지 | 겹쳐서 그려야 투명하게 보임 | 렌더링 방식의 근본적 차이 |
블렌딩 모드
블렌딩은 새로운 픽셀(Source)과 기존 픽셀(Destination)을 어떻게 혼합할지 결정합니다.
블렌딩 공식
렌더링 모드별 블렌드 상태
// Opaque: 블렌딩 비활성화
D3D11_BLEND_DESC bsDesc = {};
bsDesc.RenderTarget[0].BlendEnable = FALSE; // 블렌딩 사용 안 함
bsDesc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
GetDevice()->CreateBlendState(
&bsDesc,
blendStates[static_cast<UINT>(eBlendState::Opaque)].GetAddressOf()
);
// Cutout: 블렌딩 비활성화 (알파 테스트만 사용)
bsDesc = {};
bsDesc.RenderTarget[0].BlendEnable = FALSE;
bsDesc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
GetDevice()->CreateBlendState(
&bsDesc,
blendStates[static_cast<UINT>(eBlendState::Cutout)].GetAddressOf()
);
// Transparent: 표준 알파 블렌딩
bsDesc = {};
bsDesc.RenderTarget[0].BlendEnable = TRUE;
bsDesc.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA;
bsDesc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA;
bsDesc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;
// 알파 채널 블렌딩
bsDesc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE;
bsDesc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_ZERO;
bsDesc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD;
bsDesc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
GetDevice()->CreateBlendState(
&bsDesc,
blendStates[static_cast<UINT>(eBlendState::Transparent)].GetAddressOf()
);
C++
복사
렌더링 모드별 블렌딩 설정 상세
렌더 모드 | BlendEnable | SrcBlend | DestBlend | 설명 |
Opaque | FALSE | - | - | 블렌딩 없이 픽셀을 완전히 덮어씁니다. 깊이 테스트만 사용하며 가장 빠른 렌더링 모드입니다. |
Cutout | FALSE | - | - | 픽셀 셰이더에서 discard로 픽셀을 폐기합니다. 블렌딩 없이 알파 테스트만 수행하여 Opaque와 유사한 성능을 제공합니다. |
Transparent | TRUE | SRC_ALPHA | INV_SRC_ALPHA | 표준 알파 블렌딩으로 부드러운 투명 효과를 구현합니다. 정렬이 필수이며 성능 비용이 높습니다. |
표준 알파 블렌딩 분석
Transparent 모드의 블렌딩 공식을 자세히 살펴보겠습니다.
예시 계산
특수 블렌딩 모드
게임 효과를 위한 다양한 블렌딩 모드가 있습니다.
Additive (가산 블렌딩)
빛, 불, 폭발 등 발광 효과에 사용됩니다.
bsDesc.RenderTarget[0].BlendEnable = TRUE;
bsDesc.RenderTarget[0].SrcBlend = D3D11_BLEND_ONE; // 또는 SRC_ALPHA
bsDesc.RenderTarget[0].DestBlend = D3D11_BLEND_ONE;
bsDesc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;
// 결과: FinalColor = SrcColor + DestColor
C++
복사
사용 사례
•
파티클 시스템 (불꽃, 마법 효과)
•
렌즈 플레어
•
빛 줄기 (Light Shaft)
•
발광 효과 (Glow)
장점
•
정렬 불필요 (가산이므로 순서 무관)
•
여러 파티클이 겹치면 더 밝아짐
단점
•
색상이 계속 밝아져 하얗게 날아갈 수 있음 (Clamping 필요)
Multiplicative (곱셈 블렌딩)
어두워지는 효과나 색상 필터에 사용됩니다.
bsDesc.RenderTarget[0].BlendEnable = TRUE;
bsDesc.RenderTarget[0].SrcBlend = D3D11_BLEND_DEST_COLOR;
bsDesc.RenderTarget[0].DestBlend = D3D11_BLEND_ZERO;
bsDesc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;
// 결과: FinalColor = SrcColor * DestColor
C++
복사
사용 사례
•
그림자 (Shadow Map이 아닌 간단한 그림자)
•
색상 필터 (세피아, 흑백)
•
어두워지는 효과 (Vignette)
특성
•
곱셈이므로 항상 어두워짐 (밝아지지 않음)
•
(1, 1, 1)을 곱하면 변화 없음
Premultiplied Alpha
알파가 미리 곱해진 텍스처를 위한 블렌딩입니다.
bsDesc.RenderTarget[0].SrcBlend = D3D11_BLEND_ONE;
bsDesc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA;
// 텍스처 생성 시 RGB에 알파를 미리 곱함:
// color.rgb *= color.a
C++
복사
장점
•
하드 에지와 반투명 영역이 자연스럽게 블렌딩
•
UI와 폰트 렌더링에 적합
카메라 관리 시스템
씬에서 여러 카메라를 동적으로 추가하고 제거할 수 있는 시스템을 구현합니다.
void Scene::AddCamera(Camera* camera)
{
if (camera == nullptr)
return;
// 중복 체크
auto it = std::find(mCameras.begin(), mCameras.end(), camera);
if (it != mCameras.end())
return; // 이미 존재
mCameras.push_back(camera);
}
void Scene::RemoveCamera(Camera* camera)
{
auto it = std::find(mCameras.begin(), mCameras.end(), camera);
if (it != mCameras.end())
{
mCameras.erase(it);
}
}
C++
복사
카메라 우선순위와 렌더링 순서
카메라가 렌더링되는 순서는 매우 중요하며, 벡터의 순서에 따라 결정됩니다.
권장 렌더링 순서
우선순위 기반 정렬
각 카메라에 우선순위 값을 부여하고 자동 정렬하는 시스템을 구현할 수 있습니다.
class Camera : public Component
{
private:
int mPriority = 0; // 낮을수록 먼저 렌더링
public:
void SetPriority(int priority) { mPriority = priority; }
int GetPriority() const { return mPriority; }
};
void Scene::SortCamerasByPriority()
{
std::sort(mCameras.begin(), mCameras.end(),
[](Camera* a, Camera* b)
{
return a->GetPriority() < b->GetPriority();
}
);
}
C++
복사
우선순위 값 가이드
•
-100: 섀도우 맵
•
-50: 환경 맵, 반사 프로브
•
0: 메인 게임 카메라
•
50: 에디터 씬 뷰
•
100: UI 카메라
BaseRenderer 추상화 도입
렌더링 시스템의 확장성과 유지보수성을 높이기 위해 BaseRenderer 기반 클래스를 도입합니다.
BaseRenderer가 필요한 이유
현재 코드는 SpriteRenderer만 사용하지만, 실제 게임 엔진은 다양한 렌더러가 필요합니다.
다양한 렌더러 타입
•
SpriteRenderer: 2D 스프라이트와 빌보드
•
MeshRenderer: 정적 3D 메쉬
•
SkinnedMeshRenderer: 스킨 애니메이션 메쉬
•
ParticleRenderer: 파티클 시스템
•
TerrainRenderer: 지형 렌더링
•
DecalRenderer: 데칼(총알 구멍, 피 등)
•
VolumeRenderer: 볼륨메트릭 효과 (안개, 구름)
•
LineRenderer: 선 그리기
각 렌더러마다 개별적으로 처리하면 코드가 복잡해지고 유지보수가 어려워집니다.
문제가 되는 코드 예시
// 타입별로 분기 처리 - 유지보수 어려움
if (auto* sprite = GetComponent<SpriteRenderer>())
sprite->Render(view, proj);
else if (auto* mesh = GetComponent<MeshRenderer>())
mesh->Render(view, proj);
else if (auto* skinned = GetComponent<SkinnedMeshRenderer>())
skinned->Render(view, proj);
// 새 렌더러 추가 시 여기를 계속 수정해야 함
C++
복사
BaseRenderer 구조 설계
공통 인터페이스를 정의하면 다형성을 활용하여 코드를 단순화할 수 있습니다.
// 기반 클래스: 모든 렌더러의 공통 인터페이스
class BaseRenderer : public Component
{
public:
virtual ~BaseRenderer() = default;
// 순수 가상 함수: 모든 렌더러가 구현해야 함
virtual void Render(const Matrix& view, const Matrix& proj) = 0;
virtual Material* GetMaterial() const = 0;
virtual eRenderingMode GetRenderingMode() const = 0;
// 공통 기능
virtual bool IsVisible() const { return mIsVisible; }
virtual void SetVisible(bool visible) { mIsVisible = visible; }
protected:
bool mIsVisible = true;
};
// 2D 스프라이트 렌더러
class SpriteRenderer : public BaseRenderer
{
public:
void Render(const Matrix& view, const Matrix& proj) override
{
if (!mIsVisible || !mMaterial || !mMesh)
return;
// 스프라이트 렌더링 로직
mMaterial->Bind();
mMesh->Render();
}
Material* GetMaterial() const override { return mMaterial; }
eRenderingMode GetRenderingMode() const override
{
return mMaterial ? mMaterial->GetRenderingMode() : eRenderingMode::Opaque;
}
void SetMaterial(Material* material) { mMaterial = material; }
void SetMesh(Mesh* mesh) { mMesh = mesh; }
private:
Material* mMaterial = nullptr;
Mesh* mMesh = nullptr;
};
// 3D 메쉬 렌더러
class MeshRenderer : public BaseRenderer
{
public:
void Render(const Matrix& view, const Matrix& proj) override
{
if (!mIsVisible || !mMaterial || !mMesh)
return;
// 메쉬 렌더링 로직
mMaterial->Bind();
// 여러 서브메쉬 렌더링
for (size_t i = 0; i < mMesh->GetSubmeshCount(); ++i)
{
mMesh->RenderSubmesh(i);
}
}
Material* GetMaterial() const override { return mMaterial; }
eRenderingMode GetRenderingMode() const override
{
return mMaterial ? mMaterial->GetRenderingMode() : eRenderingMode::Opaque;
}
void SetMaterial(Material* material) { mMaterial = material; }
void SetMesh(Mesh* mesh) { mMesh = mesh; }
private:
Material* mMaterial = nullptr;
Mesh* mMesh = nullptr;
};
// 스킨 애니메이션 렌더러
class SkinnedMeshRenderer : public BaseRenderer
{
public:
void Render(const Matrix& view, const Matrix& proj) override
{
if (!mIsVisible || !mMaterial || !mMesh)
return;
// 본 행렬 업데이트
UpdateBoneMatrices();
// 스킨 메쉬 렌더링
mMaterial->Bind();
mMaterial->SetBoneMatrices(mBoneMatrices);
mMesh->Render();
}
Material* GetMaterial() const override { return mMaterial; }
eRenderingMode GetRenderingMode() const override { return eRenderingMode::Opaque; }
private:
void UpdateBoneMatrices()
{
// 애니메이션 본 변환 계산
// ...
}
Material* mMaterial = nullptr;
Mesh* mMesh = nullptr;
std::vector<Matrix> mBoneMatrices;
};
C++
복사
개선된 CollectRenderables
BaseRenderer를 사용하면 코드가 획기적으로 단순해집니다.
void Scene::CollectRenderables(
std::vector<GameObject*>& opaqueList,
std::vector<GameObject*>& cutoutList,
std::vector<GameObject*>& transparentList) const
{
for (Layer* layer : mLayers)
{
if (layer == nullptr)
continue;
std::vector<GameObject*>& gameObjects = layer->GetGameObjects();
for (GameObject* gameObj : gameObjects)
{
if (gameObj == nullptr)
continue;
// BaseRenderer로 추상화 - 타입에 관계없이 동일하게 처리
BaseRenderer* renderer = gameObj->GetComponent<BaseRenderer>();
if (renderer == nullptr || !renderer->IsVisible())
continue;
// 렌더링 모드에 따라 분류
switch (renderer->GetRenderingMode())
{
case graphics::eRenderingMode::Opaque:
opaqueList.push_back(gameObj);
break;
case graphics::eRenderingMode::CutOut:
cutoutList.push_back(gameObj);
break;
case graphics::eRenderingMode::Transparent:
transparentList.push_back(gameObj);
break;
}
}
}
}
C++
복사
코드 개선 효과
•
단일 타입(BaseRenderer*)으로 모든 렌더러 처리
•
새 렌더러 추가 시 CollectRenderables 수정 불필요
•
타입 체크와 캐스팅 제거로 코드 간결화
BaseRenderer의 이점 정리
측면 | 구체적인 이점 |
코드 일관성 | 모든 렌더러를 동일한 방식으로 처리하여 코드가 단순해집니다. 타입별 분기문이 불필요하며, 새로운 개발자도 쉽게 이해할 수 있는 명확한 구조를 제공합니다. |
유지보수성 | 렌더링 모드, 머티리얼 접근, 가시성 체크 등 공통 속성과 메서드를 BaseRenderer에서 관리하므로 중복 코드가 제거됩니다. 버그 수정도 한 곳에서만 하면 모든 렌더러에 적용됩니다. |
다형성 활용 | 하나의 벡터(std::vector<BaseRenderer*>)에 모든 타입의 렌더러를 저장하고 순회할 수 있습니다. 메모리 효율적이고 캐시 친화적인 데이터 구조로 관리됩니다. |
렌더링 최적화 | 전체 렌더러를 대상으로 프러스텀 컬링, 오클루전 컬링, LOD(Level of Detail) 시스템을 통합적으로 적용할 수 있습니다. 정렬과 배치 처리도 렌더러 타입에 관계없이 일관되게 수행됩니다. |
구조 확장성 | 새로운 렌더러(예: VolumeRenderer, CableRenderer, WaterRenderer)를 추가해도 기존 렌더링 파이프라인 코드를 전혀 수정할 필요가 없습니다. BaseRenderer를 상속받고 필요한 메서드만 오버라이드하면 즉시 시스템에 통합됩니다. |
테스트 용이성 | Mock 렌더러를 쉽게 만들어 유닛 테스트를 작성할 수 있습니다. 렌더링 로직을 독립적으로 테스트하고 검증할 수 있습니다. |
DirectXTex 외부 프로젝트 통합
DirectXTex는 Microsoft에서 제공하는 텍스처 처리 라이브러리로, 다양한 이미지 포맷 로딩과 GPU 연산을 지원합니다.
DirectXTex의 주요 기능
•
다양한 포맷 지원: DDS, HDR, TGA, PNG, JPEG, BMP, WIC 포맷
•
텍스처 압축: BC1~BC7 블록 압축 및 압축 해제
•
밉맵 생성: 자동 밉맵 체인 생성
•
큐브맵 처리: 환경 맵과 반사 맵 생성
•
이미지 변환: 리사이징, 회전, 플립
•
노멀맵 생성: 높이맵에서 노멀맵 자동 생성
•
GPU 가속: DirectCompute를 활용한 빠른 처리
외부 프로젝트를 솔루션에 추가하는 방식
프로젝트 파일(.vcxproj)을 직접 솔루션에 포함시키는 방식과 vcpkg 같은 패키지 관리자를 사용하는 방식이 있습니다.
직접 추가 방식의 장점
내부 디버깅 가능
라이브러리 소스 코드가 프로젝트에 포함되어 있으면 내부 동작을 직접 추적할 수 있습니다.
•
F11 스텝 인: 라이브러리 함수 호출 시 내부 구현으로 진입
•
Call Stack 분석: 전체 호출 스택 확인 가능
•
브레이크포인트: 라이브러리 내부에 브레이크포인트 설정
•
변수 검사: 라이브러리 내부 변수 값 확인
// 라이브러리 함수 호출
HRESULT hr = DirectX::LoadFromWICFile(
filename,
DirectX::WIC_FLAGS_NONE,
&metadata,
image
);
// F11을 누르면 LoadFromWICFile 내부 구현으로 이동 가능
// 텍스처 로딩 과정의 각 단계를 직접 확인하며 디버깅
C++
복사
빌드 설정 직접 조정
라이브러리의 빌드 설정을 프로젝트 요구사항에 맞게 커스터마이징할 수 있습니다.
예시: DirectXTex에서 BC7 압축을 사용하지 않는다면 해당 코드를 프로젝트에서 제외하여 빌드 시간을 단축할 수 있습니다.
의존성 독립성
vcpkg를 설치하지 않아도 프로젝트를 빌드할 수 있습니다.
•
Git 저장소 클론 후 즉시 빌드 가능
•
팀원 간 개발 환경 차이로 인한 문제 최소화
•
CI/CD 파이프라인 구성 단순화
버전 고정
특정 버전의 라이브러리를 프로젝트와 함께 유지하여 예상치 못한 업데이트로 인한 호환성 문제를 방지합니다.
•
안정적인 버전 유지
•
라이브러리 업데이트 시 충분한 테스트 후 적용
•
릴리즈 빌드의 재현성 보장
Visual Studio 통합
.vcxproj와 .filters 파일을 포함하면 솔루션 탐색기에서 편리하게 관리할 수 있습니다.
•
폴더 구조로 파일 정리
•
파일 검색과 네비게이션 용이
•
프로젝트 간 의존성 자동 관리
직접 추가 방식의 단점
업데이트 불편함
라이브러리에 버그 수정이나 새 기능이 추가되어도 수동으로 파일을 교체하고 병합해야 합니다.
•
Git Submodule을 사용하지 않으면 더욱 번거로움
•
수동 병합 시 충돌 해결 필요
•
업데이트 이력 추적 어려움
의존성 충돌 위험
여러 외부 프로젝트가 서로 다른 설정을 사용하면 충돌이 발생할 수 있습니다.
•
STL 버전 불일치
•
CRT(C Runtime) 설정 충돌 (MT, MD, MTd, MDd)
•
경고 레벨과 컴파일러 옵션 충





