전체 코드 : https://github.com/HyangRim/DirectX11-Engine-Client
GitHub - HyangRim/DirectX11-Engine-Client
Contribute to HyangRim/DirectX11-Engine-Client development by creating an account on GitHub.
github.com
QuadTree를 이용한 마우스 피킹 최적화
게임엔진에서 마우스 피킹(Mouse Picking)은 사용자가 3D 공간의 객체를 선택할 때 필수적인 기능이다.
하지만 씬에 수백, 수천 개의 객체가 있을 때 모든 객체에 대해 Ray-Object 교차 검사를 수행하는 것은 매우 매우 비효율적이다.
이 문제를 해결하기 위해 QuadTree 기반 공간 분할을 구현하여 마우스 피킹 성능을 대폭 개선했다.
1. 문제 상황 : 브루트 포스 방식의 한계
기존의 마우스 피킹 방식은 다음과 같은 문제점이 있었다.
// 기존 방식: 모든 객체를 순회하며 검사
shared_ptr<GameObject> PickObject(Vec2 screenPos) {
Ray ray = CreateRayFromScreen(screenPos, camera);
float minDistance = FLT_MAX;
shared_ptr<GameObject> picked;
// 모든 객체에 대해 검사 - O(n) 복잡도
for (auto& gameObject : m_gameObjects) {
if (gameObject->GetCollider() == nullptr) continue;
float distance = 0.f;
if (gameObject->GetCollider()->Intersects(ray, distance)) {
if (distance < minDistance) {
minDistance = distance;
picked = gameObject;
}
}
}
return picked;
}
성능 이슈
- 화면의 모든 객체 (수백 ~ 수천 개)를 매번 검사
- 시간 복잡도 : O(n)
- 60FPS 환경에서 프레임 드랍 발생
- CPU 사용률 증가로 인한 전체 시스템 성능 저하
QuadTree 도입 이유 - 화면에 보이지도 않는 객체까지 검사할 필요가 있을까?
QuadTree 선택 근거
- 화면 기반 분할 : 2D화면 좌표를 4개 영역으로 재귀적 분할
- Ray-Casting 최적화 : 교차하는 노드만 선택적 탐색
2. 해결책 : QuadTree 기반 공간 분할
2.1 QuadTree 구조 설계
QuadTree는 2D공간을 재귀적으로 4등분하여 객체들을 효율적으로 관리하는 자료구조
struct QuadTreeNode
{
RECT bounds; // 노드의 경계
vector<shared_ptr<GameObject>> objects; // 노드에 속한 객체들
unique_ptr<QuadTreeNode> children[4]; // 4개의 자식 노드
bool isLeaf = true; // 리프 노드 여부
static constexpr int MAX_OBJECTS = 8; // 노드당 최대 객체 수
static constexpr int MAX_DEPTH = 12; // 최대 깊이
};
class QuadTree
{
private:
unique_ptr<QuadTreeNode> m_root;
float m_screenWidth;
float m_screenHeight;
unordered_set<shared_ptr<GameObject>> m_insertedObjects;
public:
QuadTree(float _screenWidth, float _screenHeight);
void Insert(shared_ptr<GameObject> _object);
void Build();
vector<shared_ptr<GameObject>> Query(const Ray& _ray, shared_ptr<Camera> _camera);
};
핵심 파라미터
- MAX_OBJECTS = 8 : 노드당 최적 객체 수
- MAX_DEPTH = 12: 과도한 분할 방지
2.1.1 동적 객체 삽입 시스템
void QuadTree::InsertIntoNode(unique_ptr<QuadTreeNode>& node,
shared_ptr<GameObject> object, int depth) {
if (node->isLeaf) {
node->objects.push_back(object);
// 분할 조건 확인
if (node->objects.size() > MAX_OBJECTS && depth < MAX_DEPTH) {
Split(node, depth); // 동적 분할 수행
}
} else {
// 교차하는 모든 자식 노드에 삽입 (중복 허용)
RECT objBounds = GetObjectScreenBounds(object, camera);
for (int i = 0; i < 4; ++i) {
RECT intersection;
if (IntersectRect(&intersection, &objBounds, &node->children[i]->bounds)) {
InsertIntoNode(node->children[i], object, depth + 1);
}
}
}
}
설계 특징
- 중복 삽입 허용 : 경계를 걸치는 큰 객체 처리
- 적응적 분할 : 객체 밀도에 따른 자동 트리 재구성

위의 사진을 기준으로 늑대는 QuadTree 상에서 1번 영역과 2번 영역에 모두 존재하게 된다.
- 한쪽 영역에만 포함되게 될 경우(예를들어 1번에만 포함될 경우) 2번 영역에서 늑대를 선택하면 피킹이 되지 않기 때문에 중복으로 허용하는게 맞다고 판단.
2.2 3D객체의 2D 투영 최적화
3D공간의 객체를 2D 화면 공간으로 투영하는 과정이 핵심
RECT QuadTree::GetObjectScreenBounds(shared_ptr<GameObject> _object, shared_ptr<Camera> _camera)
{
// 1. 객체의 콜라이더 타입에 따른 최적화된 처리
if (_object->GetCollider()) {
auto colliderType = _object->GetCollider()->GetColliderType();
switch (colliderType) {
case ColliderType::Sphere:
return CalculateSphereScreenBounds(_object, _camera);
case ColliderType::AABBBox:
return CalculateColliderAABBScreenBounds(_object, _camera);
default:
return CalculateColliderScreenBounds(_object, _camera);
}
}
return CalculateDefaultScreenBounds(_object, _camera);
}
RECT QuadTree::CalculateSphereScreenBounds(shared_ptr<GameObject> _object, shared_ptr<Camera> _camera)
{
auto sphereCollider = static_pointer_cast<SphereCollider>(_object->GetCollider());
Vec3 worldPos = _object->GetTransform()->GetPosition();
float radius = sphereCollider->GetRadius() * _object->GetTransform()->GetScale().x;
// 구의 중심을 화면 좌표로 변환
Viewport viewport = GRAPHICS->GetViewport();
Matrix worldMatrix = Matrix::Identity;
Matrix viewMatrix = _camera->GetViewMatrix();
Matrix projMatrix = _camera->GetProjectionMatrix();
Vec3 screenCenter = viewport.Project(worldPos, worldMatrix, viewMatrix, projMatrix);
// 구의 가장자리 점들을 화면 좌표로 변환하여 바운딩 박스 계산
Vec3 rightPoint = worldPos + _camera->GetTransform()->GetRight() * radius;
Vec3 upPoint = worldPos + _camera->GetTransform()->GetUp() * radius;
Vec3 screenRight = viewport.Project(rightPoint, worldMatrix, viewMatrix, projMatrix);
Vec3 screenUp = viewport.Project(upPoint, worldMatrix, viewMatrix, projMatrix);
float radiusX = abs(screenRight.x - screenCenter.x);
float radiusY = abs(screenUp.y - screenCenter.y);
return {
(LONG)(screenCenter.x - radiusX),
(LONG)(screenCenter.y - radiusY),
(LONG)(screenCenter.x + radiusX),
(LONG)(screenCenter.y + radiusY)
};
}
2.3 효율적인 객체 삽입 및 분할
객체가 너무 많아지면 노드를 4등분하는 로직
RECT QuadTree::GetObjectScreenBounds(shared_ptr<GameObject> _object, shared_ptr<Camera> _camera)
{
// 1. 객체의 콜라이더 타입에 따른 최적화된 처리
if (_object->GetCollider()) {
auto colliderType = _object->GetCollider()->GetColliderType();
switch (colliderType) {
case ColliderType::Sphere:
return CalculateSphereScreenBounds(_object, _camera);
case ColliderType::AABBBox:
return CalculateColliderAABBScreenBounds(_object, _camera);
default:
return CalculateColliderScreenBounds(_object, _camera);
}
}
return CalculateDefaultScreenBounds(_object, _camera);
}
RECT QuadTree::CalculateSphereScreenBounds(shared_ptr<GameObject> _object, shared_ptr<Camera> _camera)
{
auto sphereCollider = static_pointer_cast<SphereCollider>(_object->GetCollider());
Vec3 worldPos = _object->GetTransform()->GetPosition();
float radius = sphereCollider->GetRadius() * _object->GetTransform()->GetScale().x;
// 구의 중심을 화면 좌표로 변환
Viewport viewport = GRAPHICS->GetViewport();
Matrix worldMatrix = Matrix::Identity;
Matrix viewMatrix = _camera->GetViewMatrix();
Matrix projMatrix = _camera->GetProjectionMatrix();
Vec3 screenCenter = viewport.Project(worldPos, worldMatrix, viewMatrix, projMatrix);
// 구의 가장자리 점들을 화면 좌표로 변환하여 바운딩 박스 계산
Vec3 rightPoint = worldPos + _camera->GetTransform()->GetRight() * radius;
Vec3 upPoint = worldPos + _camera->GetTransform()->GetUp() * radius;
Vec3 screenRight = viewport.Project(rightPoint, worldMatrix, viewMatrix, projMatrix);
Vec3 screenUp = viewport.Project(upPoint, worldMatrix, viewMatrix, projMatrix);
float radiusX = abs(screenRight.x - screenCenter.x);
float radiusY = abs(screenUp.y - screenCenter.y);
return {
(LONG)(screenCenter.x - radiusX),
(LONG)(screenCenter.y - radiusY),
(LONG)(screenCenter.x + radiusX),
(LONG)(screenCenter.y + radiusY)
};
}
3. 최적화된 마우스 피킹 구현
3.1 쿼리 최적화
이제 마우스 위치 주변의 객체들만 선별적으로 검사
vector<shared_ptr<GameObject>> QuadTree::Query(const Ray& _ray, shared_ptr<Camera> _camera)
{
vector<shared_ptr<GameObject>> result;
if (m_root) {
QueryNode(m_root, _ray, _camera, result);
}
return result;
}
void QuadTree::QueryNode(const unique_ptr<QuadTreeNode>& _node, const Ray& _ray,
shared_ptr<Camera> _camera, vector<shared_ptr<GameObject>>& _result)
{
if (!_node) return;
// Ray가 노드 영역과 교차하는지 확인
if (!RayIntersectsAABB(_ray, _node->bounds, _camera)) {
return; // 교차하지 않으면 이 노드와 모든 자식 노드 스킵
}
// 현재 노드의 객체들 추가
for (auto& obj : _node->objects) {
if (IsObjectVisible(obj, _camera)) {
_result.push_back(obj);
}
}
// 자식 노드들 재귀 검사
if (!_node->isLeaf) {
for (int i = 0; i < 4; ++i) {
QueryNode(_node->children[i], _ray, _camera, _result);
}
}
}
3.2 통합된 마우스 피킹 시스템
최종적으로 SceneObjectManager에서 최적화된 피킹 로직을 구현
shared_ptr<GameObject> SceneObjectManager::PickObject(Vec2 screenPos, shared_ptr<Camera> camera)
{
Ray ray = CreateRayFromScreen(screenPos, camera);
// QuadTree를 이용해 후보 객체들만 선별
vector<shared_ptr<GameObject>> candidates = m_quadTree->Query(ray, camera);
// 추가 필터링: 가시성 및 거리 기반 선별
vector<shared_ptr<GameObject>> validCandidates;
Vec2 screenCenter(screenPos.x, screenPos.y);
for (auto& obj : candidates) {
if (!obj->GetActive()) continue;
if (!IsObjectVisible(obj, camera)) continue;
// 화면 상에서의 거리 계산으로 추가 최적화
RECT objScreenBounds = m_quadTree->GetObjectScreenBounds(obj, camera);
Vec2 objCenter(
(objScreenBounds.left + objScreenBounds.right) * 0.5f,
(objScreenBounds.top + objScreenBounds.bottom) * 0.5f
);
float screenDistance = Vec2::Distance(screenCenter, objCenter);
if (screenDistance < 200.0f) { // 화면상 거리 임계값
validCandidates.push_back(obj);
}
}
// 최종 Ray-Object 교차 검사는 선별된 객체들에만 수행
float minDistance = FLT_MAX;
shared_ptr<GameObject> picked;
for (auto& gameObject : validCandidates) {
if (camera->IsCulled(gameObject->GetLayerIndex())) continue;
if (gameObject->GetCollider() == nullptr) continue;
float distance = 0.f;
if (gameObject->GetCollider()->Intersects(ray, distance)) {
if (distance < minDistance) {
minDistance = distance;
picked = gameObject;
}
}
}
return picked;
}
4. 성능 최적화
4.1 다단계 컬링 시스템
bool QuadTree::IsObjectVisible(shared_ptr<GameObject> object, shared_ptr<Camera> camera) {
// 1. 거리 기반 컬링
float distance = Vec3::Distance(worldPos, cameraPos);
if (distance > 500.f) return false;
// 2. 백페이스 컬링 (카메라 뒤쪽)
float dot = dirToObj.Dot(cameraLook);
if (dot < -0.1f) return false;
// 3. FOV 기반 시야각 검사
float horizontalAngle = XMConvertToDegrees(atan2(abs(right), forward));
if (horizontalAngle > horizontalFOV / 2.0f + 20.0f) return false;
return true;
}
4.2 업데이트 시스템
void SceneObjectManager::UpdateQuadTree() {
// 변화 감지 기반 업데이트
static unordered_map<shared_ptr<GameObject>, Vec3> lastObjectPositions;
bool objectMoved = false;
for (auto& object : gameObjects) {
Vec3 curPos = object->GetTransform()->GetPosition();
auto it = lastObjectPositions.find(object);
if (it != lastObjectPositions.end()) {
float distance = Vec3::Distance(it->second, curPos);
if (distance > 0.1f) { // 최소 이동 거리 임계값
objectMoved = true;
break;
}
}
}
// 필요한 경우에만 재구성
if (objectMoved || cameraChanged) {
quadTree->Rebuild();
}
}
최적화 포인트
- 증분 업데이트 : 변경된 객체만 재계산
- 임계값 기반 : 미세한 움직임 무시로 불필요한 연산 방지
- 카메라 변화 감지 : 시점 변경 시에만 재구성
4.3 Ray-AABB 교차 최적화
void QuadTree::QueryNode(const unique_ptr<QuadTreeNode>& _node, const Ray& _ray,
shared_ptr<Camera> _camera, vector<shared_ptr<GameObject>>& _result)
{
if (!_node) return;
// ★ 핵심: Ray가 이 노드 영역과 교차하는지 먼저 검사
if (!RayIntersectsAABB(_ray, _node->bounds, _camera)) return; // 교차 안하면 바로 리턴!
// 교차하는 경우에만 노드 내부 객체들 검사
for (auto& obj : _node->objects) {
if (IsObjectVisible(obj, _camera)) {
_result.push_back(obj);
}
}
// 자식 노드들도 재귀적으로 검사
if (!_node->isLeaf) {
for (int i = 0; i < 4; ++i) {
QueryNode(_node->children[i], _ray, _camera, _result);
}
}
}
bool QuadTree::RayIntersectsAABB(const Ray& _ray, const RECT& _rect, shared_ptr<Camera> _camera)
{
// 1. 3D Ray를 2D 화면 좌표로 변환
Vec2 rayStart = WorldToScreen(_ray.position, _camera);
Vec2 rayEnd = WorldToScreen(_ray.position + _ray.direction * 1000.0f, _camera);
// 2. 화면 밖 Ray는 조기 제외
Viewport viewport = GRAPHICS->GetViewport();
if ((rayStart.x < -1000 && rayEnd.x < -1000) || /* ... */) {
return false;
}
// 3. 2D 선분과 사각형(노드 영역)의 교차 검사
return LineIntersectsRect(rayStart, rayEnd, _rect);
}
Ray와 화면상 영역에 대해 QuadTree의 노드와 Ray의 충돌 검사를 미리 진행해서 불필요한 연산을 최소화
흐름
- 마우스 클릭 -> Ray와 교차하는 노드만 방문 -> 해당 노드의 객체만 검사
5. 충돌 검사 시스템
QuadTree는 피킹 최적화 뿐만 아니라 충돌 검사 시스템에서도 둥일한 공간 분할 최적화 효과를 제공한다
5.1 기존 브루트 포스 충돌 검사의 한계
void Scene::CheckCollision()
{
// 모든 객체 쌍을 검사 (n * (n-1) / 2)
for (uint32 idx = 0; idx < colliders.size(); ++idx) {
for (uint32 jdx = idx + 1; jdx < colliders.size(); ++jdx) {
// 1000개 객체 = 약 500,000번 검사!
if (colliders[idx]->Intersects(colliders[jdx])) {
// 충돌 처리...
}
}
}
}
성능 문제
- 객체 1000개 -> 499,500번 검사
- 객체 수 증가 시 제곱으로 연산량 폭증
- 멀리 떨어진 객체들도 무조건 검사
5.2 QuadTree 충돌 검사의 핵심
1. 3단계 계층적 충돌 검사
void QuadTree::CheckCollisionsInTree(shared_ptr<Camera> _camera,
unordered_map<ULONG64, bool>& _collisionMap)
{
unordered_set<ULONG64> processedPairs;
// 1단계: 노드 내부 충돌
CheckCollisionsInNode(m_root, _collisionMap, processedPairs);
// 2단계: 노드 경계 충돌 (큰 객체 대응)
CheckBoundaryCollisions(m_root, _collisionMap, processedPairs);
}
2. 노드별 지역 충돌 검사
void QuadTree::CheckCollisionsInNode(const unique_ptr<QuadTreeNode>& _node,
unordered_map<ULONG64, bool>& _collisionMap,
unordered_set<ULONG64>& _processedPairs)
{
// 현재 노드의 객체들끼리만 검사 (MAX_OBJECTS = 8개)
auto& objects = _node->objects;
for (size_t i = 0; i < objects.size(); ++i) {
for (size_t j = i + 1; j < objects.size(); ++j) {
// 최대 8 * 7 / 2 = 28번 검사 (vs 전체 검사)
ProcessCollisionPair(objects[i]->GetCollider(),
objects[j]->GetCollider(),
_collisionMap, _processedPairs);
}
}
// 자식 노드들 재귀 검사
if (!_node->isLeaf) {
for (int i = 0; i < 4; ++i) {
CheckCollisionsInNode(_node->children[i], _collisionMap, _processedPairs);
}
// 인접 자식 노드들 간 교차 검사
for (int i = 0; i < 4; ++i) {
for (int j = i + 1; j < 4; ++j) {
CheckCrossNodeCollisions(_node->children[i], _node->children[j],
_collisionMap, _processedPairs);
}
}
}
}
3. 인접 노드 간 교차 충돌 처리
void QuadTree::CheckCrossNodeCollisions(const unique_ptr<QuadTreeNode>& _node1,
const unique_ptr<QuadTreeNode>& _node2,
unordered_map<ULONG64, bool>& _collisionMap,
unordered_set<ULONG64>& _processedPairs)
{
// 두 노드가 인접한지 확인
if (!AreNodesAdjacent(_node1->bounds, _node2->bounds)) return;
// 인접한 노드들의 모든 객체 조합 검사
for (auto& obj1 : _node1->objects) {
for (auto& obj2 : _node2->objects) {
if (obj1 == obj2) continue;
ProcessCollisionPair(obj1->GetCollider(), obj2->GetCollider(),
_collisionMap, _processedPairs);
}
}
}
4. 경계 객체 처리
void QuadTree::CheckBoundaryCollisions(const unique_ptr<QuadTreeNode>& _node,
unordered_map<ULONG64, bool>& _collisionMap,
unordered_set<ULONG64>& _processedPairs)
{
if (!_node || _node->isLeaf) return;
// 경계를 넘나드는 큰 객체들과 자식 노드 객체들 검사
for (auto& obj : _node->objects) {
if (!obj->GetCollider()) continue;
for (int i = 0; i < 4; ++i) {
CheckObjectWithNode(obj, _node->children[i], _collisionMap, _processedPairs);
}
}
}
5.3 충돌 최적화
1. 중복 검사 방지
void QuadTree::ProcessCollisionPair(shared_ptr<BaseCollider> _collider1,
shared_ptr<BaseCollider> _collider2,
unordered_map<ULONG64, bool>& _collisionMap,
unordered_set<ULONG64>& _processedPairs)
{
// ID 정렬하여 중복 방지
COLLIDER_ID id;
if (_collider1->GetID() < _collider2->GetID()) {
id.left_id = _collider1->GetID();
id.right_id = _collider2->GetID();
} else {
id.left_id = _collider2->GetID();
id.right_id = _collider1->GetID();
}
// 이미 처리된 쌍인지 확인
if (_processedPairs.find(id.ID) != _processedPairs.end()) return;
_processedPairs.insert(id.ID);
// 실제 충돌 검사 및 이벤트 처리...
}
2. 공간 접근성 활용
bool QuadTree::AreNodesAdjacent(const RECT& _rect1, const RECT& _rect2)
{
// 두 사각형이 인접한지 확인 (경계가 닿거나 겹치는지)
return !(_rect1.right < _rect2.left || _rect2.right < _rect1.left ||
_rect1.bottom < _rect2.top || _rect2.bottom < _rect1.top);
}
6. 성능 최적화 결과
6.1 시간 복잡도 개선
- 기존 방식 : O(n) - 모든 객체 검사
- QuadTree 방식 : O(log n) - 평균적으로 로그 시간
6.2 실제 성능 측정 결과
#if _DEBUG
// 성능 정보 출력
const auto& stats = m_quadTree->GetStats();
cout << "=== 피킹 성능 정보 ===" << endl;
cout << "전체 객체 수: " << m_gameObjects.size() << endl;
cout << "초기 후보 객체 수: " << candidates.size() << endl;
cout << "유효 후보 객체 수: " << validCandidates.size() << endl;
cout << "필터링 효과: " << (candidates.size() - validCandidates.size()) << "개 제외" << endl;
cout << "효율성: " << (100.0f * validCandidates.size() / m_gameObjects.size()) << "%" << endl;
cout << "쿼리 시간: " << stats.lastQueryTime.count() << "μs" << endl;
#endif
측정 결과 (1000개 객체 기준)
- 전체 객체 검사 : 1000개 -> QuadTree 후보 : 15~30개
- 검사시간 : 15.2ms -> 0.8ms (약 19개 향상)
- 메모리 사용량 : 기존 대비 1.2배 ( 공간 분할 트리 오버헤드 )
결론
QuadTree를 이용한 마우스 피킹과 충돌 최적화를 통해 다음과 같은 성과를 얻음
- 성능향상
- 평균 19배 빠른 피킹 속도
- 대용량 씬(수천~수만 개 객체) 에서도 실시간 반응성 보장
- CPU 사용률 현저히 감소
- 확장성
- 객체 수에 따른 성능 저하 최소화
- 동적 씬 변화에 대한 효율적 대응
- 메모리 사용량 최적화
- 활용 범위
- 2D/3D 혼합 환경에서 효과적
'DirectX11 > Eternal Return 모작' 카테고리의 다른 글
| [DirectX 11 Eternal Return 모작] 18. Behavior Tree ( 행동 트리 ) (0) | 2026.01.07 |
|---|---|
| [DirectX 11 Eternal Return 모작] 16. Direct2D 텍스트 렌더링 (0) | 2025.09.03 |
| [DirectX 11 Eternal Return 모작] 15. HUD와 상태바 (0) | 2025.09.03 |
| [DirectX 11 Eternal Return 모작] 14. UI (0) | 2025.09.03 |
| [DirectX 11 Eternal Return 모작] 13. 전투 , 데미지 시스템 (0) | 2025.09.03 |