데미지 넘버(Damage Numbers) 최적화: 연사 스킬 사용 시 텍스트 메쉬 및 캔버스 UI 병목 해결

데미지 넘버(Damage Numbers) 최적화: 연사 스킬 사용 시 텍스트 메쉬 및 캔버스 UI 병목 해결

초당 수십 회 이상 발생하는 연사 스킬 사용 시 캔버스 재배치와 GC 부하로 인한 프레임 드랍을 분석하고 오브젝트 풀링, 캔버스 분리, 파티클 시스템 기반 렌더링을 통한 최적화 기법을 소개합니다.

액션 RPG나 핵앤슬래시 장르 게임에서는 분당 수백 회 이상의 타격이 발생하는 연사 스킬(Multi-hit Skill)이나 지속 피해(DoT) 메커니즘을 자주 사용합니다. 이때 화면에 실시간으로 출력되는 Floating Damage Text(데미지 넘버)는 타격감을 극대화하는 중요한 요소이지만 적절한 최적화 없이 구현하면 순식간에 심각한 프레임 드랍(Frame Drop)을 유발합니다.

이번 글에서는 데미지 텍스트 생성 과정에서 발생하는 주요 CPU/GPU 병목 원인을 살펴보고 이를 해결하기 위한 4가지 실전 최적화 기법을 다룹니다.

연사 스킬 출력 시 발생하는 캔버스 및 메쉬 병목 원인

단순해 보이는 숫자 텍스트 표시 연출이 게임 성능에 악영향을 미치는 주요 원인은 크게 세 가지로 나뉩니다.

  1. 캔버스 재배치(Canvas Rebatching) 오버헤드: Unity UI(uGUI) 패러다임에서 캔버스 하위의 UI 엘리먼트 위치, 크기, 색상 등이 변경되면 해당 캔버스 전체의 정점 메쉬를 다시 계산(Rebuild)합니다. 연사 스킬로 수십 개의 텍스트가 동시에 이동하고 투명도가 변하면 매 프레임 캔버스 리배치가 일어납니다.
  2. CPU 텍스트 메쉬 생성 오버헤드: TextMeshPro(TMP)나 일반 텍스트 컴포넌트는 문자열(String) 데이터를 파싱하여 글자별 정점(Vertex), UV, 삼각면(Triangle) 데이터를 CPU에서 생성합니다. 동적 문자열 할당과 메쉬 생성이 빈번하면 CPU 렌더링 스레드 병목이 커집니다.
  3. 가비지 컬렉션(GC) 프레임 드랍: InstantiateDestroy를 반복하거나 텍스트 갱신 과정에서 string 연결 연산(damage.ToString())을 대량 수행하면 힙 메모리 파편화와 잦은 GC가 발생합니다.

연사 스킬 사용 시 데미지 텍스트로 인한 캔버스 리배칭과 병목 현상

아래 다이어그램은 최적화되지 않은 기존 데미지 텍스트 렌더링 파이프라인에서 병목이 발생하는 지점을 나타냅니다.

flowchart TD
    A[타격 이벤트 발생] --> B[Instantiate 데미지 UI]
    B --> C[string 포맷팅 메모리 할당]
    C --> D[TMP 정점 메쉬 CPU 재생성]
    D --> E[메인 Canvas 전체 Rebatching]
    E --> F[Draw Call 급증 및 프레임 드랍]

데미지 넘버 최적화 핵심 전략 4가지

1. 오브젝트 풀링과 동적 캔버스 분리 (Canvas Isolation)

가장 기초적이면서 유효한 접근법은 캔버스의 영향 범위 분리입니다. 체력바나 미니맵 등 정적 UI가 포함된 메인 HUD 캔버스에 데미지 텍스트를 배치하면 데미지 텍스트 하나의 이동으로 인해 HUD 전체 메쉬가 재계산됩니다.

  • 캔버스 분리: 둥둥 떠다니는 데미지 텍스트 전용 World Space 혹은 Screen Space 독립 Canvas를 생성합니다. 이렇게 하면 데미지 텍스트가 움직여도 메인 HUD UI의 캔버스에는 영향을 주지 않습니다.
  • 오브젝트 풀링: 미리 정해진 개수의 텍스트 GameObjects를 생성해 두고 재사용하여 Instantiate/Destroy 부하를 전면 제거합니다.

2. 짧은 시간 내 데미지 수치 합산 (Damage Aggregation)

연속 타격 스킬의 경우 프레임당 여러 개의 숫자를 모두 별도로 출력하는 대신 아주 짧은 시간 동안 입력된 데미지를 단일 텍스트로 합산하여 표시합니다.

단위 시간 동안 누적된 타격 횟수가 nn개일 때 화면에 출력할 합산 데미지 DtotalD_{\text{total}}은 다음과 같이 표현할 수 있습니다.

Dtotal=i=1ndiD_{\text{total}} = \sum_{i=1}^{n} d_i

이 방식을 적용하면 가독성이 향상될 뿐만 아니라 화면에 생성되는 텍스트 개수 자체가 크게 줄어들어 렌더링 부담을 절반 이하로 감소시킵니다.

3. GC-Free 문자열 변환 및 TextMeshPro 버텍스 직접 갱신

C#에서 숫자 데이터를 문자열로 만들 때 발생하는 힙 할당을 방지해야 합니다. TextMeshPro의 SetText 메서드는 내부적으로 GC 할당 없는 정수 버퍼 출력 연산을 지원합니다.

// bad: 매 타격마다 새로운 string 객체 할당
textComponent.text = damageAmount.ToString(); 

// good: TMP 내장 GC-Free 정수 출력 버퍼 활용
textComponent.SetText("{0}", damageAmount);

추가적으로 위치 이동 시 Transform 컴포넌트 변형 대신 TMP의 SetVerticesDIRTY() 또는 정점 배열(TMP_TextInfo)을 직접 조작하여 메쉬 갱신 범위를 최소화할 수 있습니다.

4. 파티클 시스템 기반 렌더링 (Particle System Damage Text)

극단적인 연사 스킬이나 몬스터가 수십 마리 몰리는 핵앤슬래시 환경에서는 UI 캔버스 패러다임 자체를 벗어나는 것이 좋습니다. Particle SystemTexture Sheet Animation을 활용하면 수백 개의 숫자를 하나의 Draw Call로 렌더링할 수 있습니다.

파티클 시스템 기반 데미지 텍스트 렌더링 구조와 캔버스 방식의 비교

각 숫자 폰트의 글리프(0~9)를 아틀라스 텍스처로 묶고 파티클 시스템의 커스텀 데이터 스펙을 이용해 파티클 하나당 숫자 메쉬를 인스턴싱하여 표현하는 방식입니다. 이 구조에서는 캔버스 리배치 개념 자체가 사라지며 GPU 렌더링 성능이 비약적으로 증가합니다.


실전 C# 코드: 캔버스 분리형 고성능 데미지 풀러

아래 코드는 오브젝트 풀링, 전용 캔버스 분리, GC-Free 문자열 갱신을 적용한 Unity C# 구현 예시입니다.

using System.Collections.Generic;
using UnityEngine;
using TMPro;

public class DamageNumberPool : MonoBehaviour
{
    [SerializeField] private GameObject damageTextPrefab;
    [SerializeField] private Canvas isolatedCanvas; // 독립된 캔버스
    [SerializeField] private int initialPoolSize = 50;

    private readonly Queue<GameObject> pool = new Queue<GameObject>();

    private void Awake()
    {
        InitializePool();
    }

    private void InitializePool()
    {
        for (int i = 0; i < initialPoolSize; i++)
        {
            GameObject obj = Instantiate(damageTextPrefab, isolatedCanvas.transform);
            obj.SetActive(false);
            pool.Enqueue(obj);
        }
    }

    public void SpawnDamageNumber(Vector3 worldPosition, int damageAmount, bool isCritical)
    {
        GameObject textObj = pool.Count > 0 ? pool.Dequeue() : Instantiate(damageTextPrefab, isolatedCanvas.transform);
        textObj.transform.position = worldPosition;
        textObj.SetActive(true);

        TMP_Text tmpText = textObj.GetComponent<TMP_Text>();
        if (tmpText != null)
        {
            // GC 할당 없이 정수 출력
            tmpText.SetText("{0}", damageAmount);
            tmpText.color = isCritical ? Color.red : Color.white;
        }

        StartCoroutine(DespawnRoutine(textObj, 0.8f));
    }

    private System.Collections.IEnumerator DespawnRoutine(GameObject textObj, float delay)
    {
        yield return new WaitForSeconds(delay);
        textObj.SetActive(false);
        pool.Enqueue(textObj);
    }
}

최적화 효과 및 결론

위 최적화 기법을 적용했을 때 관찰할 수 있는 주요 성능 개선 지표는 다음과 같습니다.

  • Canvas Rebuild Time: 메인 HUD 캔버스 분리 및 텍스트 독립으로 Canvas.SendWillRenderCanvases CPU 소요 시간이 크게 줄어듭니다.
  • GC Allocations: 매 타격 시 발생하던 string 인스턴스화 부하가 제거되어 메인 스레드 스터터링(Stuttering) 현상이 예방됩니다.
  • Draw Calls: 파티클 시스템 기반 렌더링 전환 시 수백 개의 데미지 메쉬가 1~2개의 Draw Call로 일괄 배치(Batching)됩니다.

연사 스킬과 다중 타격 연출은 게임의 몰입감을 높여주지만 렌더링 파이프라인의 특성을 고려하지 않으면 최악의 병목 지점이 됩니다. 프로젝트 사양과 타격 빈도에 맞춰 오브젝트 풀링, 캔버스 분리, 파티클 시스템 적용 등 단계별 최적화를 도입해 보시기 바랍니다.

#Unity#Optimization#UI#TextMeshPro#Performance

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs