Unity C#로 구현하는 Octree 기반 3D 공간 분할과 오클루전 디텍션

Octree로 3D 오브젝트를 재귀적으로 분할하고 카메라 프러스텀·차폐 검사 대상을 줄이는 방법을 Unity C# 예제로 설명합니다. 노드 분할 기준, 레이캐스트 기반 가시성 판정, 디버깅과 성능 측정까지 실전 구현 흐름을 다룹니다.

✓ 복사 완료!
챕터일반 가이드
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity C#로 구현하는 Octree 기반 3D 공간 분할과 오클루전 디텍션

핵심 요약

Octree는 하나의 3D 영역을 최대 8개의 자식 노드로 반복 분할해, 카메라와 무관한 오브젝트까지 전수 검사하는 비용을 줄이는 공간 분할 자료구조다. Unity에서는 먼저 Octree 노드 단위로 프러스텀 컬링을 수행하고 살아남은 후보에만 Physics.Raycast 또는 Renderer.bounds 기반 차폐 검사를 적용하면 오클루전 디텍션 비용을 통제할 수 있다.

핵심은 최대 깊이, 노드당 최대 오브젝트 수, 오브젝트 경계가 여러 자식 노드에 걸칠 때의 보관 규칙을 장면 규모에 맞게 조정하는 것이다.

Octree 기반 3D 공간 분할은 왜 필요한가?

수백 개 이상의 렌더러가 있는 3D 장면에서 매 프레임 모든 오브젝트에 대해 가시성 검사를 하면 비용이 빠르게 커진다. 단순한 전체 순회는 오브젝트 수를 NN이라고 할 때 검사 횟수가 대략 O(N)O(N)이다. 반면 Octree는 공간적으로 가까운 오브젝트를 같은 노드에 묶어, 카메라와 관계없는 넓은 영역을 노드 단위로 한 번에 제외한다.

Octree의 한 노드는 축 정렬 경계 상자(AABB, Axis-Aligned Bounding Box)를 가지며 분할 시 각 축을 절반으로 나눠 최대 8개의 자식 노드를 만든다. 깊이가 dd일 때 이론상 최대 노드 수는 다음과 같다.

∑i=0d8i=8d+1−17\sum_{i=0}^{d}8^i=\frac{8^{d+1}-1}{7}

물론 실제 구현에서는 빈 노드를 만들지 않고 오브젝트 수와 깊이 제한으로 분할을 중단해야 메모리와 순회 비용을 억제할 수 있다.

방식검사 단위장점주의점
전체 순회Renderer 1개구현이 단순함큰 장면에서 매 프레임 비용 증가
Uniform Grid고정 크기 셀이동 오브젝트 갱신이 단순함밀도 차이가 큰 장면에서 비효율
Octree적응형 3D 노드빈 공간과 밀집 구역을 함께 다루기 좋음경계 오브젝트 규칙이 필요
BVH객체 경계 볼륨광선·충돌 질의에 유리함동적 객체 재구성이 복잡할 수 있음

루트 노드가 8개 자식 노드로 분할되고 카메라 프러스텀 밖 노드가 제외되는 Octree 구조

Octree는 어떻게 분할하고 오브젝트를 저장할까?

Step 1. 루트 영역과 분할 정책을 정한다

루트 노드는 플레이 공간 전체를 덮는 Bounds로 만든다. 월드가 매우 넓다면 모든 월드를 하나의 거대한 Octree에 넣기보다 청크나 씬 단위로 루트 트리를 나누는 편이 관리하기 쉽다.

권장 초기값은 다음과 같다.

파라미터예시 값의미
maxDepth5~8재귀 분할의 최대 깊이
maxObjectsPerNode8~16이 수를 초과하면 분할 시도
minNodeSize1~4m지나치게 작은 노드 생성 방지
occlusionCheckDistance100~300m차폐 검사 대상 거리 제한

Step 2. 오브젝트가 완전히 들어가는 자식 노드를 찾는다

오브젝트의 Renderer.bounds가 자식 노드 하나에 완전히 포함되면 그 자식에 넣는다. 여러 자식 경계를 가로지르는 큰 오브젝트는 현재 부모 노드에 남긴다. 이를 피하려고 여러 노드에 중복 삽입하면 조회 결과를 중복 제거해야 하고 동적 오브젝트 갱신 비용도 커진다.

using System.Collections.Generic;
using UnityEngine;

public sealed class OctreeNode
{
    private readonly Bounds bounds;
    private readonly int depth;
    private readonly int maxDepth;
    private readonly int maxObjectsPerNode;
    private readonly List<Renderer> objects = new();
    private OctreeNode[] children;

    public OctreeNode(Bounds bounds, int depth, int maxDepth, int maxObjectsPerNode)
    {
        this.bounds = bounds;
        this.depth = depth;
        this.maxDepth = maxDepth;
        this.maxObjectsPerNode = maxObjectsPerNode;
    }

    public void Insert(Renderer renderer)
    {
        if (children != null)
        {
            int childIndex = GetContainingChildIndex(renderer.bounds);
            if (childIndex >= 0)
            {
                children[childIndex].Insert(renderer);
                return;
            }
        }

        objects.Add(renderer);

        if (children == null && depth < maxDepth && objects.Count > maxObjectsPerNode)
        {
            Split();
            RedistributeObjects();
        }
    }

    private int GetContainingChildIndex(Bounds objectBounds)
    {
        for (int i = 0; i < children.Length; i++)
        {
            if (Contains(children[i].bounds, objectBounds))
                return i;
        }

        return -1;
    }

    private static bool Contains(Bounds outer, Bounds inner)
    {
        Vector3 min = inner.min;
        Vector3 max = inner.max;
        return outer.Contains(min) && outer.Contains(max);
    }
}

Step 3. 부모에 남은 오브젝트까지 반드시 검사한다

부모 노드에 남긴 큰 오브젝트는 자식 노드를 순회할 때 누락되기 쉽다. 따라서 가시성 수집 함수는 현재 노드의 objects를 먼저 처리하고 이후 자식 노드를 방문해야 한다.

flowchart TD
    A[루트 Octree 노드] --> B{노드 AABB가 카메라 프러스텀과 교차하는가?}
    B -->|아니오| C[하위 노드 전체 제외]
    B -->|예| D[현재 노드의 오브젝트 후보 수집]
    D --> E{자식 노드가 있는가?}
    E -->|예| F[각 자식 노드 재귀 검사]
    E -->|아니오| G[후보 오브젝트 차폐 검사]
    F --> G
    G --> H[보이는 오브젝트만 렌더링 목록에 유지]

Unity에서 Octree 프러스텀 컬링을 어떻게 구현할까?

Unity는 GeometryUtility.CalculateFrustumPlanes(Camera)로 카메라 프러스텀 평면 6개를 만들 수 있다. GeometryUtility.TestPlanesAABB에 노드의 AABB를 전달하면 노드 단위의 빠른 1차 컬링이 가능하다.

public void CollectFrustumCandidates(
    Plane[] frustumPlanes,
    List<Renderer> results)
{
    if (!GeometryUtility.TestPlanesAABB(frustumPlanes, bounds))
        return;

    foreach (Renderer renderer in objects)
    {
        if (renderer != null && renderer.enabled)
            results.Add(renderer);
    }

    if (children == null)
        return;

    foreach (OctreeNode child in children)
        child.CollectFrustumCandidates(frustumPlanes, results);
}

이 단계의 목적은 “화면에 있을 가능성이 있는 후보”를 만드는 것이다. 프러스텀 안에 있어도 벽 뒤에 가려진 오브젝트는 포함될 수 있으므로 다음 단계에서 오클루전 디텍션을 적용한다.

Raycast 기반 오클루전 디텍션은 어떻게 구현할까?

카메라에서 대상 중심으로 광선을 쏜다

가장 이해하기 쉬운 방식은 카메라 위치에서 Renderer.bounds.center를 향해 레이캐스트를 수행하는 것이다. 가장 먼저 충돌한 콜라이더가 대상 자신이 아니면 대상 중심은 가려진 것으로 판단할 수 있다.

public static bool IsOccluded(
    Camera camera,
    Renderer target,
    LayerMask occluderMask,
    float maxDistance)
{
    Bounds targetBounds = target.bounds;
    Vector3 origin = camera.transform.position;
    Vector3 point = targetBounds.center;
    Vector3 direction = point - origin;
    float distance = direction.magnitude;

    if (distance <= 0.001f || distance > maxDistance)
        return false;

    if (!Physics.Raycast(
            origin,
            direction / distance,
            out RaycastHit hit,
            distance,
            occluderMask,
            QueryTriggerInteraction.Ignore))
    {
        return false;
    }

    return hit.collider.GetComponentInParent<Renderer>() != target;
}

단, 중심점 하나만 검사하면 얇은 기둥이나 창문 틈 때문에 실제로는 대부분 가려진 오브젝트가 보이는 것으로 남을 수 있다. 품질이 더 필요하다면 중심과 AABB의 화면상 주요 지점 3~5개를 표본으로 검사하고 일정 비율 이상 막혔을 때 가려진 것으로 판정한다.

카메라에서 오브젝트 경계 중심과 여러 표본 지점으로 레이를 보내 벽 뒤 대상의 가시성을 판정하는 모습

Renderer.enabled를 바로 토글할 때의 주의점

직접 renderer.enabled = false로 숨길 수 있지만 다음 프레임에 다시 검사할 대상 목록 자체에서 빠지지 않도록 Octree의 논리 데이터와 렌더링 상태를 분리해야 한다. 또한 Renderer를 껐다 켜는 방식은 그림자, LOD, 배칭 정책과 상호작용할 수 있다.

대규모 씬이라면 다음 우선순위가 안전하다.

  1. Octree로 프러스텀 밖 노드를 제외한다.
  2. 가까운 거리 또는 비용이 큰 메시만 Raycast 차폐 검사 후보로 제한한다.
  3. 매 프레임 전체 후보를 검사하지 말고 프레임별 예산을 둔다.
  4. 실제 GPU 병목이라면 Unity Occlusion Culling, LOD Group, SRP Batcher와 함께 프로파일링한다.

오클루전 디텍션에서 성능 문제가 생기는 이유는 무엇인가?

Raycast 기반 차폐 검사는 CPU 물리 질의다. 후보가 MM개이고 대상당 표본을 SS개 검사하면 최악의 경우 프레임당 질의 수는 M×SM \times S가 된다. Octree가 프러스텀 후보를 줄여도 카메라 근처에 많은 오브젝트가 모여 있으면 Raycast 비용이 다시 병목이 될 수 있다.

문제원인대응 방법
프레임 타임 급증모든 후보를 매 프레임 Raycast프레임당 검사 예산과 라운드 로빈 적용
오브젝트가 깜빡임단일 표본이 경계에서 흔들림여러 표본, 히스테리시스, 최소 유지 시간 적용
큰 건물이 누락됨경계 오브젝트를 잘못된 자식에 삽입완전 포함이 아닐 때 부모에 보관
이동 객체가 엉뚱한 노드에 남음삽입 뒤 위치 갱신 없음제거 후 재삽입 또는 동적 전용 트리 사용
가려졌는데 계속 렌더링됨차폐체에 콜라이더·레이어 없음전용 Occluder 레이어와 콜라이더 점검

특히 동적 오브젝트가 많다면 매 이동마다 정적 Octree를 재구성하지 않는 편이 낫다. 정적 지형·건물용 Octree와 이동 캐릭터·투사체용 별도 리스트 또는 느슨한 Octree(Loose Octree)를 분리하면 갱신 비용을 낮출 수 있다.

구현 품질을 높이는 디버깅 방법

OnDrawGizmosSelected에서 노드 Bounds를 그리면 과도한 분할, 빈 노드, 경계 오브젝트의 부모 보관 여부를 빠르게 확인할 수 있다. 개발 빌드에서는 프레임마다 다음 수치를 기록해 최적화 전후를 비교한다.

  • 전체 등록 렌더러 수
  • 프러스텀 통과 노드 수와 후보 렌더러 수
  • Raycast 실행 횟수
  • 가려짐 판정 수
  • ProfilerMarker로 측정한 수집·물리 질의 시간

Unity Profiler에서 CPU Usage의 Physics.Raycast와 Octree 순회 함수의 시간을 함께 확인해야 한다. 컬링 로직이 렌더러 수는 줄였지만 물리 질의 비용을 늘렸다면 차폐 검사 거리나 검사 주기를 먼저 낮추는 것이 합리적이다.

자주 묻는 질문 (FAQ)

Unity Occlusion Culling이 있는데 직접 Octree를 구현해야 하나요?

항상 그럴 필요는 없다. 베이크된 정적 실내 씬은 Unity Occlusion Culling이 적합하다. 런타임에 생성되는 오브젝트, 게임 규칙용 근접 탐색, AI 시야 후보 축소처럼 렌더링 외 공간 질의에도 같은 인덱스를 쓰고 싶을 때 Octree 구현 가치가 크다.

Octree의 최대 깊이는 몇으로 시작하면 좋나요?

정답은 장면 크기와 밀도에 따라 다르다. 우선 maxDepth 6, 노드당 8~16개 오브젝트로 시작하고 Gizmo와 Profiler에서 리프 노드 밀도 및 순회 시간을 확인해 조정한다. 깊이를 무조건 늘리면 노드 수와 재귀 비용이 증가한다.

Raycast 오클루전 판정을 매 프레임 해야 하나요?

카메라와 대상이 거의 움직이지 않는다면 매 프레임일 필요가 없다. 거리별로 검사 주기를 다르게 두고 예를 들어 먼 대상은 4~8프레임마다 검사하면 시각적 차이 없이 CPU 비용을 줄일 수 있다.

마무리

Octree는 렌더링 전용 기능이라기보다 3D 월드의 후보 집합을 작게 만드는 기반 인덱스다. 먼저 노드 단위 프러스텀 컬링으로 넓은 영역을 제거하고 남은 일부에만 거리 제한이 있는 오클루전 검사를 적용하자. 이후 Unity Profiler 수치로 maxDepth, 노드당 오브젝트 수, Raycast 예산을 조정하면 장면 특성에 맞는 균형점을 찾을 수 있다.