Unity Decal 시스템으로 벽 피격 흔적과 사체 잔해 메모리 최적화하기

Unity URP Decal Projector와 풀링, 수명 관리, 거리 기반 제거를 적용해 벽 피격 흔적과 사체 잔해가 누적될 때 발생하는 메모리·드로우콜·오버드로우 문제를 줄이는 실전 최적화 가이드입니다.

✓ 복사 완료!
챕터VFX & 파티클 시스템
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity Decal 시스템으로 벽 피격 흔적과 사체 잔해 메모리 최적화하기

핵심 요약

벽의 총알 자국, 혈흔, 사체 잔해를 매번 독립 GameObject와 머티리얼로 남기면 메모리, 드로우콜, 오버드로우가 함께 증가한다. Unity 2022.3 LTS 기준으로 URP Decal Projector, 제한된 풀, 우선순위 기반 교체, 거리·시간 수명 정책을 결합하면 시각적 밀도는 유지하면서 누적 비용을 예측 가능한 상한 안에 가둘 수 있다.

핵심은 흔적을 “영구 오브젝트”가 아니라 “예산이 있는 임시 시각 효과”로 취급하는 것이다. 화면에 보이지 않거나 중요도가 낮은 Decal과 잔해부터 재활용한다.

Decal 시스템은 왜 벽 피격 흔적에 적합한가?

벽 피격 흔적은 대개 얇은 평면 메시를 벽면에 붙이는 방식으로 구현한다. 하지만 벽의 굴곡이나 법선 방향을 매번 맞춰야 하고 벽과의 깊이 충돌로 깜빡이는 Z-fighting도 관리해야 한다.

URP의 Decal Projector는 투영 영역 안에 있는 렌더러 표면에 Decal 머티리얼을 적용한다. 총알 자국이나 혈흔처럼 표면을 따라 붙어 보여야 하는 효과에 특히 적합하다. Decal Layer를 사용하면 캐릭터, 배경, 프롭처럼 적용 대상을 분리할 수도 있다.

방식장점주의할 점적합한 대상
Quad 메시구현이 단순하고 셰이더 제어가 자유로움벽면 정렬, Z-fighting, 메시 누적 관리 필요평평한 벽의 소수 흔적
URP Decal Projector표면 투영과 레이어 분리가 쉬움투영 범위와 동시 개수를 제한해야 함총알 자국, 혈흔, 그을음
메시 잔해입체감과 실루엣 표현이 좋음메시·콜라이더·리지드바디 비용이 큼가까이 보이는 사체 파편
파티클/메시 파티클다수 표현에 유리개별 상호작용과 영구 보존에는 부적합짧게 흩날리는 잔해

URP Decal Projector가 벽면에 총알 자국과 혈흔을 투영하는 예시

벽 피격 흔적과 사체 잔해는 왜 메모리를 계속 점유하는가?

문제는 텍스처 한 장의 크기보다 “누적된 인스턴스”에서 시작되는 경우가 많다. 피격마다 Instantiate()를 호출하고 사체마다 리지드바디와 콜라이더를 유지하면 전투 시간이 길수록 관리 대상이 늘어난다.

특히 다음 비용을 함께 확인해야 한다.

비용 항목흔한 원인대응 방법
Managed GC피격 시 새 객체·람다·문자열 생성오브젝트 풀과 사전 할당 사용
Native 메모리메시, 텍스처, 물리 컴포넌트가 붙은 인스턴스 누적최대 개수와 수명 제한
CPUUpdate(), 물리 시뮬레이션, 컬링 대상 증가정지 후 물리 비활성화, 중앙 관리
GPU 드로우콜머티리얼 인스턴스와 개별 메시 증가공유 머티리얼, 배칭 가능 구조
GPU 오버드로우큰 투영 박스와 반투명 Decal 중첩투영 크기·알파 영역·겹침 제한

투영된 픽셀 수는 대략 투영 면적과 화면 점유율에 비례한다. Decal의 화면상 너비와 높이를 각각 두 배로 늘리면 처리해야 하는 영역은 약 네 배가 될 수 있다.

Ascreen∝w×hA_{screen} \propto w \times h

따라서 “흔적 수”뿐 아니라 Decal Projector의 Size와 불필요하게 큰 투영 깊이도 성능 예산에 포함해야 한다.

Decal 메모리 예산은 어떻게 정할까?

1. 장면별 동시 표시 상한을 먼저 정한다

플랫폼과 카메라 구성을 기준으로 예산을 정한다. 예를 들어 일반 전투 공간에서 벽 흔적 80개, 사체 12개, 근접 잔해 40개처럼 상한을 둔다. 이 숫자는 정답이 아니라 프로파일링을 시작할 기준점이다.

가시성보다 흔적의 지속성이 중요한 게임이라도 모든 흔적을 영구 보존할 필요는 없다. 플레이어가 가까이 보고 있는 흔적, 최근에 발생한 흔적, 중요한 이벤트 흔적에 점수를 더 준다.

2. 새 흔적이 들어오면 가장 낮은 우선순위를 재활용한다

큐의 가장 오래된 항목만 제거하면 보스전 혈흔이나 퀘스트 연출 흔적이 먼저 사라질 수 있다. 시간, 카메라 거리, 중요도를 조합한 점수로 교체 대상을 선택하면 제어가 쉬워진다.

score=importance+recentness−distanceWeight×distancescore = importance + recentness - distanceWeight \times distance

importance는 일반 총알 자국보다 폭발 자국이나 스크립트 이벤트 흔적에 높은 값을 주는 식으로 설계한다.

3. 보이지 않는 효과에는 짧은 수명을 적용한다

화면 밖 Decal은 즉시 삭제하기보다 짧은 유예 시간을 둔다. 카메라가 빠르게 회전했을 때 흔적이 부자연스럽게 사라지는 것을 줄일 수 있다. 긴 전투에서는 시간 기반 수명과 개수 기반 강제 회수를 함께 적용하는 편이 안전하다.

Unity URP Decal Projector 풀을 어떻게 구현할까?

다음 예시는 DecalProjector 프리팹을 미리 생성하고 풀이 가득 찼을 때 가장 오래된 항목을 재사용하는 최소 구현이다. 각 프리팹은 같은 Material 에셋을 공유해야 하며 런타임에 new Material()을 호출하지 않는다.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering.Universal;

public sealed class DecalPool : MonoBehaviour
{
    [SerializeField] private DecalProjector decalPrefab;
    [SerializeField] private int capacity = 80;
    [SerializeField] private float lifetime = 25f;

    private readonly Queue<DecalProjector> available = new();
    private readonly Queue<ActiveDecal> active = new();

    private struct ActiveDecal
    {
        public DecalProjector projector;
        public float expireAt;
    }

    private void Awake()
    {
        for (int i = 0; i < capacity; i++)
        {
            DecalProjector projector = Instantiate(decalPrefab, transform);
            projector.gameObject.SetActive(false);
            available.Enqueue(projector);
        }
    }

    public void Spawn(Vector3 point, Vector3 normal, float size)
    {
        ReclaimExpired();

        if (available.Count == 0)
        {
            ActiveDecal oldest = active.Dequeue();
            oldest.projector.gameObject.SetActive(false);
            available.Enqueue(oldest.projector);
        }

        DecalProjector projector = available.Dequeue();
        projector.transform.SetPositionAndRotation(
            point + normal * 0.01f,
            Quaternion.LookRotation(-normal));
        projector.size = new Vector3(size, size, 0.2f);
        projector.fadeFactor = 1f;
        projector.gameObject.SetActive(true);

        active.Enqueue(new ActiveDecal
        {
            projector = projector,
            expireAt = Time.time + lifetime
        });
    }

    private void ReclaimExpired()
    {
        while (active.Count > 0 && active.Peek().expireAt <= Time.time)
        {
            ActiveDecal expired = active.Dequeue();
            expired.projector.gameObject.SetActive(false);
            available.Enqueue(expired.projector);
        }
    }
}

피격 지점에서는 RaycastHit.point와 RaycastHit.normal을 전달한다. 코드의 normal * 0.01f 오프셋은 표면과 투영기가 완전히 겹칠 때 발생할 수 있는 시각적 충돌을 완화하기 위한 값이며 월드 스케일에 맞춰 조정해야 한다.

피격 지점의 법선 방향에 맞춰 풀링된 Decal을 배치하고 오래된 항목을 회수하는 과정

사체 잔해는 어떤 순서로 최적화해야 할까?

사체 잔해는 Decal보다 물리 비용이 크다. 따라서 잔해의 “생성”, “물리 안정화”, “정리”를 분리해 관리하는 것이 좋다.

  1. 생성 단계: 폭발 시 모든 조각에 리지드바디를 붙이지 않는다. 카메라 가까이에서 보일 조각만 물리를 활성화하고 나머지는 파티클 또는 비물리 메시로 대체한다.
  2. 안정화 단계: 잔해 속도가 충분히 낮아지면 Rigidbody를 sleep 상태에 맡기기보다 isKinematic = true로 전환하고 콜라이더를 끈다. 계속 충돌할 이유가 없는 잔해의 물리 월드 등록을 줄일 수 있다.
  3. 정리 단계: 거리와 시야 밖 시간에 따라 페이드아웃한 뒤 풀로 반환한다. 근거리에서 갑자기 사라지지 않도록 화면 안에서는 수명 연장을 적용한다.
using UnityEngine;

public sealed class DebrisLifetime : MonoBehaviour
{
    [SerializeField] private Rigidbody body;
    [SerializeField] private Collider debrisCollider;
    [SerializeField] private float settleSpeed = 0.15f;
    [SerializeField] private float settleDelay = 1.5f;

    private float settledAt = -1f;

    private void FixedUpdate()
    {
        if (body.isKinematic)
            return;

        if (body.velocity.sqrMagnitude < settleSpeed * settleSpeed)
        {
            if (settledAt < 0f)
                settledAt = Time.time;

            if (Time.time - settledAt >= settleDelay)
            {
                body.isKinematic = true;
                debrisCollider.enabled = false;
            }
        }
        else
        {
            settledAt = -1f;
        }
    }
}

Unity 버전에 따라 속성 이름이 다를 수 있다. Unity 2022.3의 기본 Rigidbody API에서는 velocity를 사용한다. Unity 6에서는 linearVelocity도 사용할 수 있으므로 프로젝트의 버전에 맞는 속성으로 통일한다.

Decal 셰이더와 렌더링 설정에서 확인할 항목

Decal Projector 범위를 작게 유지한다

  • Size.z는 필요한 표면 깊이만 덮도록 작게 설정한다.
  • 하나의 대형 혈흔 Decal보다 작은 Decal 여러 개가 항상 유리한 것은 아니다. 실제 카메라 거리에서 오버드로우와 드로우콜을 함께 측정한다.
  • 벽 반대편이나 인접 방까지 투영되는 경우 Decal Layer와 투영 깊이를 먼저 점검한다.

텍스처와 머티리얼을 공유한다

  • 총알 자국 변형은 서로 다른 머티리얼을 만들기보다 텍스처 아틀라스나 셰이더 파라미터로 처리한다.
  • 알파 채널 주변의 넓은 빈 영역은 오버드로우를 키운다. Decal 텍스처의 여백과 투영 크기를 함께 줄인다.
  • Decal Normal Map과 MAOS 맵은 시각적 차이가 작은 원거리 흔적에 생략하는 LOD 정책을 고려한다.

품질 설정별 상한을 다르게 둔다

품질 등급벽 Decal 상한 예시물리 잔해 상한 예시권장 정책
Low258원거리 흔적 즉시 회수, Normal Map 생략
Medium5016시간·거리 기반 회수
High8032중요도 기반 교체와 짧은 페이드 적용

예시 수치는 시작점일 뿐이다. 실제 기준은 목표 프레임 시간 안에서 GPU와 CPU가 각각 허용하는 여유다. Unity Profiler의 CPU Usage, Memory, Rendering 모듈과 Frame Debugger를 함께 사용해 추적한다.

무엇을 프로파일링해야 병목을 정확히 찾을 수 있을까?

  1. 같은 장소에서 피격 흔적과 잔해를 0개, 25개, 50개, 최대 개수로 늘린다.
  2. 각 구간에서 프레임 시간, GC Alloc, Batches, SetPass Calls, 삼각형 수, 물리 업데이트 시간을 기록한다.
  3. 흔적 수를 절반으로 줄인 결과와 투영 크기를 절반으로 줄인 결과를 비교한다. GPU 병목이라면 후자가 더 큰 효과를 낼 수 있다.
  4. 모바일과 저사양 PC는 실제 목표 기기에서 확인한다. 에디터 수치만으로 최종 예산을 결정하지 않는다.

Instantiate와 Destroy 호출이 완전히 사라졌는지 렌더러 수만 줄고 머티리얼 복제가 남아 있지는 않은지도 확인해야 한다. 풀링은 생성 비용을 줄이지만 활성 상태인 Decal과 잔해 수가 무제한이면 렌더링·물리 비용은 그대로 남는다.

자주 묻는 질문 (FAQ)

Decal Pool만 적용하면 메모리 최적화가 끝나는가?

아니다. 풀링은 반복 생성과 파괴에서 생기는 할당·해제 비용을 줄인다. 활성 Decal 수, 투영 면적, 머티리얼 수, 텍스처 메모리도 별도로 제한해야 한다.

벽에 붙은 Decal이 다른 면까지 비쳐 보이는 이유는 무엇인가?

대부분 Decal Projector의 투영 깊이(Size.z)가 과도하게 크기 때문이다. 투영 깊이를 줄이고 필요한 경우 Decal Layer로 대상 렌더러를 분리한다.

사체를 바로 삭제하지 않고 풀링해야 하는 이유는 무엇인가?

전투 중 사체와 잔해가 자주 생성된다면 풀링은 반복적인 인스턴스 생성·파괴를 줄인다. 다만 장면 전환 뒤에도 풀이 필요 없다면 해당 씬의 풀을 정리하는 정책은 별도로 둬야 한다.

정리

Decal 최적화의 목표는 흔적을 적게 만드는 것이 아니라 흔적의 최대 비용을 설계하는 것이다. URP Decal Projector에 풀링과 수명 정책을 적용하고 사체 잔해는 물리가 멈춘 뒤 콜라이더와 리지드바디를 비활성화한다. 마지막으로 실제 목표 기기에서 흔적 개수와 투영 면적을 바꿔 측정하면 시각 품질을 해치지 않는 안전한 예산을 정할 수 있다.