RealTimeDestructibleMesh - Network CollisionSystem

조창근·2026년 2월 12일

RealTimeDestructibleMesh

목록 보기
4/4
post-thumbnail

Cell Box Collision: 파괴된 메쉬의 충돌 처리 최적화

지난 글에서 예고한 대로, 이번에는 총알로 파괴된 메쉬의 충돌(Collision) 처리를 어떻게 최적화했는지 이야기하겠습니다.

1. 문제 정의: "벽을 쏘면 뭐가 남는가?"

파괴 시스템에서 가장 간과하기 쉬운 부분이 바로 충돌(Collision)입니다.

플레이어가 총으로 벽에 구멍을 뚫었다고 생각해봅시다. 화면에는 구멍이 보입니다. 그런데 캐릭터가 그 구멍을 통과하려 하면? 충돌 처리가 업데이트 안 되어있으면 보이지 않는 벽에 막힙니다. 반대로, 충돌을 아예 꺼버리면 아직 멀쩡한 벽을 뚫고 지나가버립니다.

즉, 렌더링 메쉬와 충돌 메쉬는 항상 동기화되어야 합니다.

기존 접근의 문제

가장 단순한 해법은 Boolean 연산으로 메쉬를 잘라낸 뒤, 그 결과를 충돌에도 그대로 쓰는 것입니다. 클라이언트에서는 이게 통합니다. 하지만 데디케이티드 서버에서는 이야기가 다릅니다.

  • 서버는 렌더링을 하지 않습니다. GPU도 없습니다.
  • 그런데 Boolean 연산은 CPU에서 돌아가는 무거운 메쉬 연산입니다.
  • 서버는 모든 플레이어의 파괴 요청을 처리해야 합니다.
  • 결과: Boolean 연산이 서버 Tick을 잡아먹어서 서버 히칭(Hitching) 발생.

서버에 렌더링 메쉬 품질의 충돌이 필요할까요? 전혀 아닙니다. 서버가 알아야 할 건 딱 하나입니다: "이 위치에 벽이 있는가, 없는가?"

여기서 Cell Box Collision이 등장합니다.

2. 핵심 아이디어: 복셀로 근사하자

RealTimeDestructibleMesh는 이미 Grid Cell System을 가지고 있습니다. 메쉬 전체가 3D 격자(Grid)로 분할되어 있고, 각 셀(Cell)마다 "존재/파괴" 상태를 알고 있습니다.

이걸 그대로 충돌에 쓰면 됩니다.

원리는 단순합니다:

  • 살아있는 Cell → Box Collision 있음
  • 파괴된 Cell → Box Collision 없음

삼각형 단위의 정밀한 메쉬 충돌 대신, Cell 크기의 박스 충돌로 근사(Approximation)하는 겁니다. 약간의 충돌 정밀도를 포기하는 대신, 연산량을 극적으로 줄입니다.

3. Surface Voxel: 표면 셀만 골라내기

그런데 살아있는 셀 전부에 박스를 달면 어떻게 될까요?

100 x 100 x 100 그리드라면 최대 1,000,000개의 박스입니다. 물리 엔진이 이걸 감당할 수가 없습니다.

여기서 핵심 최적화가 등장합니다: Surface Voxel(표면 복셀).

bool URealtimeDestructibleMeshComponent::IsCellExposed(int32 CellId) const
{
    const FIntArray& Neighbors = GridCellLayout.GetCellNeighbors(CellId);

    // 이웃이 6개 미만이면 그리드 경계 → 표면
    if (Neighbors.Values.Num() < 6)
    {
        return true;
    }

    // 이웃 중 하나라도 파괴되었으면 → 새로운 표면
    for (int32 NeighborId : Neighbors.Values)
    {
        if (CellState.DestroyedCells.Contains(NeighborId))
        {
            return true;
        }
    }

    return false; // 내부 셀 → 콜리전 불필요
}

벽의 바깥 표면구멍 내벽(새로 노출된 표면)에만 충돌 박스가 생깁니다. 벽 속의 내부 셀(Interior Cell)은 어차피 외부 물체와 접촉할 일이 없으므로 과감하게 건너뜁니다.

실제 측정에서 전체 셀의 약 1~5%만 표면 셀이었습니다. 1,000,000개 중 대략 10,000~50,000개만 박스를 만들면 되는 겁니다.

4. Collision Chunk: 청크 분할 전략

표면 셀만 쓴다고 해도, 하나의 거대한 BodySetup에 수만 개의 박스를 때려넣으면 물리 엔진이 비효율적으로 돌아갑니다. 특히 파괴 시 BodySetup을 통째로 재생성해야 하니까요.

해결책은 공간 분할(Spatial Partitioning)입니다. 전체 메쉬 바운드를 N x N x N 청크로 나누고, 각 청크마다 독립적인 BodySetup을 가지게 합니다.

// 목표 청크 수 계산 (기본: 셀 500개당 1청크)
const int32 TargetChunkCount = FMath::Max(1, TotalCells / TargetCellsPerCollisionChunk);

// 3차원이므로 세제곱근으로 각 축 분할 수 계산
CollisionChunkDivisions = FMath::RoundToInt(
    FMath::Pow((float)TargetChunkCount, 1.0f / 3.0f)
);

TargetCellsPerCollisionChunk의 기본값은 500입니다. 10,000개의 유효 셀이 있다면:

  • 목표 청크 수 = 10,000 / 500 = 20
  • 세제곱근 ≈ 2.7 → 3
  • 실제 청크: 3 x 3 x 3 = 27개
구분단일 BodySetup청크 분할 (채택)
재빌드 범위전체해당 청크만
파괴 시 비용O(전체 표면 셀)O(청크 내 표면 셀)
메모리단일 할당청크별 할당
병렬성불가독립 처리 가능

5. Dirty 마킹: 바뀐 부분만 업데이트

파괴가 일어나면 전체 충돌을 다시 계산하는 게 아니라, 영향받는 청크만 dirty로 마킹합니다.

// 파괴된 셀의 청크 + 이웃 셀의 청크를 dirty 마킹
TSet<int32> DirtyChunkIndices;
for (int32 CellId : DestructionResult.NewlyDestroyedCells)
{
    // 파괴된 셀이 속한 청크
    int32 ChunkIdx = GetCollisionChunkIndexForCell(CellId);
    DirtyChunkIndices.Add(ChunkIdx);

    // 이웃 셀들의 청크도 dirty (새로 표면이 될 수 있으므로)
    const FIntArray& Neighbors = GridCellLayout.GetCellNeighbors(CellId);
    for (int32 NeighborId : Neighbors.Values)
    {
        int32 NeighborChunkIdx = GetCollisionChunkIndexForCell(NeighborId);
        DirtyChunkIndices.Add(NeighborChunkIdx);
    }
}

for (int32 ChunkIdx : DirtyChunkIndices)
{
    MarkCollisionChunkDirty(ChunkIdx);
}

왜 이웃 셀의 청크도 마킹할까요?

셀 A가 파괴되면, 그 옆에 있던 셀 B가 새로운 표면이 됩니다. B는 원래 내부 셀이라 콜리전이 없었는데, 이제는 콜리전을 추가해야 합니다. 그래서 이웃까지 포함하는 겁니다.

6. 프레임 버짓: Time-Slicing

Dirty 청크가 한꺼번에 많이 쌓일 수 있습니다. 건물 붕괴 같은 대규모 파괴 시에는 수십 개의 청크가 동시에 dirty가 됩니다. 이걸 한 프레임에 다 처리하면 스파이크가 발생합니다.

void URealtimeDestructibleMeshComponent::UpdateDirtyCollisionChunks()
{
    constexpr int32 MaxChunksPerFrame = 5; // 프레임당 최대 5개만 처리

    int32 UpdatedCount = 0;
    for (int32 i = 0; i < CollisionChunks.Num(); ++i)
    {
        if (CollisionChunks[i].bDirty)
        {
            if (UpdatedCount < MaxChunksPerFrame)
            {
                BuildCollisionChunkBodySetup(i);
                ++UpdatedCount;
            }
            // 초과분은 다음 프레임으로 자동 이월
        }
    }
}

디브리 시스템의 PendingDebrisQueue와 동일한 철학입니다. 한 프레임에 다 하지 말고, 나눠서 하자.

7. BodySetup 빌드: Box는 쿠킹이 필요 없다

각 청크를 재빌드할 때 무슨 일이 벌어지는지 봅시다.

void URealtimeDestructibleMeshComponent::BuildCollisionChunkBodySetup(int32 ChunkIndex)
{
    FCollisionChunkData& Chunk = CollisionChunks[ChunkIndex];
    FKAggregateGeom& ChunkAggGeom = Chunk.BodySetup->AggGeom;
    ChunkAggGeom.BoxElems.Reset();

    for (int32 CellId : Chunk.CellIds)
    {
        if (CellState.DestroyedCells.Contains(CellId))
            continue;  // 파괴된 셀 스킵

        if (!IsCellExposed(CellId))
            continue;  // 내부 셀 스킵

        FKBoxElem BoxElem;
        BoxElem.Center = GridCellLayout.IdToLocalCenter(CellId);
        BoxElem.X = GridCellLayout.CellSize.X;
        BoxElem.Y = GridCellLayout.CellSize.Y;
        BoxElem.Z = GridCellLayout.CellSize.Z;
        ChunkAggGeom.BoxElems.Add(BoxElem);
    }

    // 핵심: BoxElems는 Analytic Shape라 쿠킹 불필요!
    Chunk.BodySetup->bCreatedPhysicsMeshes = true;
}

여기서 아주 중요한 포인트가 있습니다. 일반적으로 UE의 커스텀 BodySetup을 쓰려면 CreatePhysicsMeshes()물리 메쉬를 쿠킹(Cooking)해야 합니다. 이 과정이 느립니다.

하지만 BoxElems, SphereElems, CapsuleElems 같은 기본 Shape(Analytic Shape)는 쿠킹이 필요 없습니다. 물리 엔진이 직접 수학적으로 처리합니다. 그래서 bCreatedPhysicsMeshes = true로 강제 플래그만 세우면 끝입니다.

이게 박스 충돌을 선택한 또 다른 이유입니다. ConvexHull이나 TriMesh 충돌이었다면 매번 쿠킹 비용이 발생했을 겁니다.

8. 서버-클라이언트 역할 분리

Cell Box Collision은 서버와 클라이언트에서 다른 역할을 수행합니다.

if (NetMode == NM_DedicatedServer)
{
    // 서버: 원본 메쉬의 충돌을 완전히 끄고, Cell Box가 모든 충돌 담당
    SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
else // NM_Client
{
    // 클라이언트: Pawn 응답만 Cell Box로 대체 (레이캐스트는 원본 메쉬 사용)
    SetCollisionResponseToChannel(ECC_Pawn, ECR_Ignore);
}

서버(Dedicated Server):

  • Boolean 연산을 하지 않으므로 정밀한 충돌 메쉬가 없습니다.
  • Cell Box가 Pawn 충돌의 유일한 소스입니다.
  • 렌더링 없음 → 메쉬 충돌 무의미 → Cell Box만으로 충분.

클라이언트(Client):

  • Boolean 연산으로 정밀한 렌더링 메쉬를 가지고 있습니다.
  • 하지만 Boolean 결과가 적용되는 타이밍과 Cell 상태 업데이트 타이밍 사이에 이 있을 수 있습니다.
  • Cell Box가 Pawn 충돌을 담당하고, 레이캐스트(총알 판정 등)는 원본 메쉬를 사용합니다.
충돌 유형서버클라이언트
Pawn (캐릭터 이동)Cell BoxCell Box
Raycast (사격 판정)Cell Box원본 메쉬 (정밀)
PhysicsBody (디브리)Cell BoxCell Box
렌더링없음Boolean 메쉬

9. 콜리전 컴포넌트의 설계

각 청크의 콜리전 컴포넌트는 아주 가볍게 설계되어 있습니다.

ChunkComp->SetStaticMesh(nullptr);       // 메쉬 없음 - 콜리전만
ChunkComp->SetHiddenInGame(true);        // 렌더링 없음
ChunkComp->SetCastShadow(false);         // 그림자 없음
ChunkComp->bAlwaysCreatePhysicsState = true;  // 메쉬 없어도 물리 생성
ChunkComp->SetCanEverAffectNavigation(false); // NavMesh 영향 없음

StaticMeshComponent를 쓰되 메쉬를 할당하지 않습니다. 오직 BodySetup만 직접 주입해서 물리 충돌만 존재하는 "유령 컴포넌트"를 만드는 겁니다.

왜 별도의 Actor가 아닌 Component로 만들었을까요? Component는 부모(RealtimeDestructibleMeshComponent)에 Attach되므로, 부모가 이동하거나 회전하면 자동으로 따라갑니다. Actor였다면 Transform 동기화를 직접 해줘야 합니다.

10. 전체 흐름 요약

[총알이 벽에 명중]
         ↓
[Cell 파괴 판정 (서버)]
  - 파괴 영역 내 Cell들의 상태를 Destroyed로 변경
         ↓
[Dirty 마킹]
  - 파괴된 Cell의 청크 → dirty
  - 파괴된 Cell의 이웃 Cell 청크 → dirty (새 표면)
         ↓
[TickComponent에서 UpdateDirtyCollisionChunks()]
  - 프레임당 최대 5개 청크만 처리 (Time-Slicing)
         ↓
[BuildCollisionChunkBodySetup()]
  - Chunk 내 살아있는 표면 Cell만 순회
  - FKBoxElem으로 변환 후 AggGeom에 추가
  - 쿠킹 스킵 (Analytic Shape)
  - BodyInstance 재생성
         ↓
[충돌 즉시 반영]
  - 총알로 뚫린 구멍: 박스 제거됨 → Pawn 통과 가능
  - 구멍 주변: 새 표면 박스 추가됨 → 정상 충돌 유지

11. 의의

이 시스템의 핵심 가치는 이렇습니다.

서버 성능: Boolean 연산 없이 정수 비교(Cell ID Lookup)만으로 충돌을 관리합니다. 서버 히칭이 사라졌습니다.

부분 업데이트: 전체가 아닌 dirty 청크만 재빌드합니다. 27개 청크 중 2~3개만 업데이트하면 되는 거죠.

쿠킹 제로: Box는 Analytic Shape라 PhysX/Chaos 쿠킹 과정이 필요 없습니다. 재빌드가 즉각적입니다.

네트워크 효율: Cell ID는 정수이므로 서버와 클라이언트가 동일한 셀 상태를 공유합니다. 충돌 메쉬 데이터를 네트워크로 전송할 필요가 없습니다. 각자 로컬에서 같은 로직을 돌리면 됩니다.

다음 글에서는 멀티쓰레드와 네트워크가 결합했을 때 생기는 문제에 대해서 다뤄보도록 하겠습니다.

0개의 댓글