Project PZ
실시간 턴제 전략 액션
  • Mobile(Android)
  • UnityEngine

담당 업무

세부 내용

UITooklit 이벤트 바인딩 시스템

UIToolkit 환경에서, element class를 통해 이벤트를 자동으로 바인딩하는 기능을 구현하였습니다.
디자이너는 UI를 새로 만들거나 수정할 때, element class를 추가하면 즉시 기능을 적용할 수 있습니다.
프로그래머는 명시된 element class에 따라 Attribute를 선언하는 것으로, 추가적인 바인딩 작업을 진행하지 않아도 됩니다.

[ElementCommand("cmd--button--brush-tool")]
private static void SelectBrushTool(VisualElement target) => History.Log("브러쉬 도구를 선택하였음");

UQuery를 통해 가져온 VisualElement에 메서드를 등록하기 위해 Reflection을 사용하였으나,
초기화 시점에 단 한번 동작하여 UIBuilder 사용 시 성능 영향이 적도록 설계하였습니다.
추후 런타임 적용을 고려하여, Source Generator를 사용하는 방식으로 전환하더라도 복잡한 수정 과정 없이 사용할 수 있도록 설계하였습니다.
추가적인 메모리 할당을 방지할 수 있도록, 외부 변수 캡처 없이 static 메서드를 호출하는 static 람다 함수를 사용하는 방식으로 구현하였습니다.

레벨 디자인 툴

절차적 레벨 생성에 사용되는 파츠 데이터를 작업할 수 있는 레벨 에디터를 구현하였습니다.

  1. Attribute 기반의 자동 이벤트 바인딩 시스템을 적용하여 디자인 작업과 유연하게 연동할 수 있는 구조를 설계하였습니다.
  2. 인게임과 동일한 프리뷰 환경: 인게임의 레벨 생성 로직을 모듈화하고 에디터 환경에 통합하여, 별도의 빌드 과정 없이 절차적 생성 결과를 빠르게 확인하며 작업할 수 있는 환경을 구축하였습니다.
  3. 파츠의 ObjectPreview 기능과 스냅샷 출력 기능을 구현하여 툴을 사용하지 않더라도 엔진 내에서 파츠의 모습을 확인할 수 있도록 하였습니다.

절차적 레벨 생성

절차적 레벨 생성

Wang Tile 알고리즘을 응용하여 절차적 지형을 생성하는 로직을 구현하였습니다.
퀘스트 진행도나 아이템 보유 여부 등 디자이너가 설정한 외부 데이터에 따라 각 파츠 Edge의 인접 조건과 오브젝트 생성 규칙이 동적으로 변경되는 구조를 설계하였습니다.

절차적 메쉬 생성

Greedy 알고리즘을 통해 절차적 메쉬 생성을 구현하였습니다.
다른 지형에 가려지거나 불필요한 정점의 생성을 가능한 방지하여 청크 단위로 나눠진 메쉬를 생성하도록 하였습니다.

월드맵

TilemapRenderer를 통해 생성된 지형의 모습과 오브젝트의 위치 등을 표시하는 월드맵 기능을 구현하였습니다.

청크 시스템

Spatial Hash Grid를 통해 월드 공간을 영역으로 나누고 활성 상태를 관리하는 청크 시스템을 구현하였습니다.
플레이어의 이동에 따라 각 청크의 활성 상태가 전환되는 시점에 월드 타임스탬프를 기록하여,
재활성화 시 공백 시간 동안의 오브젝트 상태 변화를 수식 기반으로 즉시 보정할 수 있는 구조를 설계하였습니다.

CSV Utility

별도의 파싱 로직 작성 없이 CSV 데이터를 변환할 수 있는 유틸리티를 구현하였습니다.
이름 지정 방식과 열 인덱스 지정 방식을 지원하여 데이터 테이블의 헤더 수정에 유연하게 대응할 수 있도록 하였습니다.

public class Weapon
{
    // 열 지정 방식
    [CSVIndex(0)]
    public string ItemCode;

    // 이름 지정 방식
    [CSVHeader("wpn_author")]
    public string AuthorName;

    public List<string> EnchantList;
    public EquipSlotType SlotType; // enum
}

private UniTaskVoid Start()
{
    Dictionary<string, Weapon> resultDictionary = new();
    CSVUtility.TryFromText("text...", nameof(Weapon.ItemCode), out resultDictionary);

    string resultText = string.Empty;
    CSVUtility.TryToText(resultDictionary, out resultText);
}

기본 자료형(int, string) 외에도 사용자 정의 타입(클래스나 딕셔너리 리스트 같은 복합 자료형)을 처리하기 위해,
제네릭 기반의 추가 컨버터를 구현하여 사용할 수 있습니다.
동일한 타입이라도 상황에 따라 다른 파싱 규칙을 적용할 수 있습니다.

// 1. 사용자 정의 타입 처리를 위한 컨버터 구현
// CSVConverter<T>를 상속하여 구현
public class StatConverter : CSVConverter<StatInfo>
{
    // 입력값: "HP:100|MP:50"
    public override StatInfo Read(string value)
    {
        var stat = new StatInfo();
        var parts = value.Split('|'); // 구분자 정의
        stat.HP = int.Parse(parts[0].Split(':')[1]);
        stat.MP = int.Parse(parts[1].Split(':')[1]);
        return stat;
    }

    public override string Write(StatInfo value)
    {
        return $"HP:{value.HP}|MP:{value.MP}";
    }
}

// 2. 사용자 정의 타입 컨버터 사용
public class MonsterData
{    
    [CSVIndex(0)]
    public string ID;

    // Attribute로 컨버터를 주입
    [CSVConverter(typeof(StatConverter))]
    public StatInfo BaseStat; 
}

몬스터 패턴 시스템

CSV 기반 몬스터 FSM

몬스터의 행동 패턴을 CSV 텍스트로 작성하여 FSM으로 동작시키는 시스템을 구현하였습니다.
각 행에 부여된 속성 키에 따라 내용을 다르게 파싱하는 방식으로, 새로운 상태를 생성하거나 상태별 전이 조건, 사용 스킬 등을 정의하여 사용할 수 있도록 하였습니다.

몬스터 패턴 뷰어

UIToolkit을 통해 몬스터의 패턴을 시각화하는 뷰어를 구현하였습니다.
스테이지에 생성된 몬스터의 이전 동작이나 다음 틱에 실행할 동작을 확인할 수 있도록 하였습니다.

Troubleshooting

프로토타입 지형 생성 로직을 상용 수준으로 리팩토링

문제 상황

테스트 기기 환경에서 거대한 레벨을 생성할 경우, 300초 이상의 긴 시간이 소모되는 현상이 발생하였습니다.

원인 분석

Profiler를 사용하여 분석한 결과, 심각한 GC 오버헤드와 비효율적인 연산이 핵심 원인으로 파악되었습니다.

  1. GC 오버헤드
    • class 기반 좌표 클래스의 연산자 오버로딩으로 인해 매 연산마다 발생하는 힙 할당
    • ToList, ToDictionary 등의 잦은 LINQ 호출로 인한 리사이징과 박싱
    • 잦은 Coroutine 호출
  2. 비효율적 알고리즘
    • 오버랩 체크 시 타일 개수만큼 객체를 생성하거나 전체를 순회하는 $O(N^2)$ 로직
    • 동일한 결과를 반환하는 조건 연산의 중첩 사용

해결 과정

1. GC 오버헤드 제거

좌표 클래스를 구조체로 변경하는 동시에 지형 정보를 담고 있던 string 등 참조 타입의 변수를 제거하여 GC.Alloc이 완전히 일어나지 않도록 하였습니다.
또한 LINQ 사용으로 인해 발생하는 문제점을 해결하기 위해 아래와 같이 수정하였습니다.

  • 콜렉션을 미리 생성함과 동시에 Capacity를 계산하여, 함수 내에서의 생성과 Resizing을 최소화하였습니다.
    • Memory Profiler를 통해 예약된 메모리와 콜렉션의 최종 크기가 동일함을 검증했습니다.
  • 쿼리 함수를 for/while를 사용한 순회로 변경하여 새로운 콜렉션을 생성하지 않도록 하였습니다.
  • List 순회 대신 HashSet과 Dictionary을 사용하여 평균 데이터 접근 속도를 개선하였습니다

마지막으로 코루틴은 컴파일 단계에서 클래스로 선언되어 작동하기 때문에 GC.Alloc이 일어나게 되므로, 이를 방지하기 위해 구조체 기반의 UniTask를 도입하였습니다.

2. 청크 시스템 적용

청크 시스템의 Spatial Hash Grid를 적용하여 좌표 단위로 실행하는 동작을 영역 단위로 실행하도록 수정하였습니다.
오버랩 체크, 영역 회전, 확장 등 관련 함수의 호출 횟수와 평균 데이터 접근 속도를 개선하였습니다.

3. CPU Friendly

개선된 구조에서도 일부 함수는 최악의 경우 수십만번 이상 호출되어 실행 시간에 영향을 주는 것을 확인하였습니다.
더 이상 호출 횟수를 줄이는 것은 어려웠기 때문에, 함수 자체의 동작 시간을 줄이기 위해 아래와 같은 방법을 통해 CPU의 처리 시간 감소를 도모하였습니다.

  • 좌표 구조체의 크기를 조절하여 메모리 접근 횟수를 줄였습니다.
  • 불필요한 중간 객체를 제거하고 함수 파라미터에 수식을 직접 넣어 전달하였습니다.
  • 연산자 오버로딩을 제거하고 멤버의 값을 수정하는 연산 함수로 변경하였습니다.

결과

최악의 케이스(모든 청크 검사 실패, 기획상 최대 지형 크기 적용 등)를 가정한 테스트 환경에서,
평균 생성 시간이 380초 → 14초로 단축되었습니다.
또한 핵심 생성 로직에서 필수적인 할당을 제외한 Zero Allocation을 달성하여, GC Spike 현상을 방지할 수 있었습니다.