오늘은 C프로젝트에서 Shader를 이용하여 오브젝트가 물 background 위에 있을 때 잠겨있는 듯한 모습을 연출하려고 한다.

 

유니티에서 Shader를 만드는 방법은 크게 두 가지 방식이 있다.

첫 번째론 셰이더 언어를 이용하여 직접 코드를 작성하여 만드는 방식이고,

두 번째 방법으론 유니티에서 제공하는 비주얼 툴인 Shader Graph를 이용하여 만드는 방식이다.

나는 두 가지 방법 중 후자를 선택하여 만들어보려고 한다.

 

시작에 앞서 나는 유니티 프로젝트를 진행하면서 한 번도 셰이더를 적용하여 결과물을 만들어본 적이 없다...

그렇다보니 막히는 부분이 너무 많았다... ㅠ

그렇기 때문에 먼저 깊게까진 아니더라도 Rendering Pipeline에 대해서 공부를 해보았다.

그 이후 한 예제를 들어 셰이더를 만드는 방법에 대한 유튜브 영상도 참고해 보았다.

 

아래의 개발 과정은 셰이더에 대한 기본적인 지식이 있다는 가정하에 설명이 되므로

혹시 보는 과정 중 설명이 부족한 부분이 있다면 직접 찾아서 궁금증을 해결하자!

 

이제 본격적으로 직접 셰이더 그래프를 이용하여 만들어 볼 시간이다!

그러기 위해선 먼저 셰이더 그래프를 먼저 생성해 준다.

셰이더 그래프를 만드는 종류는 여러 가지가 있는데 이 중 현재 필요한 형태의 셰이더 그래프를 생성해 주면 된다.

기본적으로 이 프로젝트는 지금 당장은 아니지만 URP를 적용하여

Lighting에 대한 처리를 하기 위해 "Sprite Lit Shader Graph"로 생성했다.

 

우선 아이디어가 필요했다.

어떤 방식을 이용하여 물에 잠겨있는 듯한 연출을 표현할지...

처음 든 생각은 float형으로 depth 필드를 만든 이후 depth의 크기만큼 texture 일부분의 투명도는 0으로 해주었다.

우선 Texture의 특정 y값을 기준으로 아랫부분은 투명도를 0으로 해주기 위해 UV 좌표에서 y를 받아왔다.

적용할 머테리얼에서도 특정 y값을 조절할 수 있게 하기 위해

float형으로 _Depth라는 프로퍼티를 만들어 해당 값과 y를 빼주었다.

이후 step 노드를 통해 예쁘게 다듬어준다.

 

이렇게 나온 결과와 Main Texture의 알파값을 곱해준다면 특정 y값을 기준으로 아랫부분은 투명도가 0이 될 것이고 윗부분은 정상적으로 렌더링 되는 결과가 나올 것이다.

완성된 그래프를 저장한 이후 새로운 머테리얼을 만들어 방금 전에 만들어준 셰이더 그래프를 드래그 앤 드롭하여 적용시켜 준다.

만들어진 머테리얼을 오브젝트에 적용하여 결과를 확인하면 위와 같이 잘 적용되는 모습을 볼 수 있다.

하지만 물 위에 있는 듯한 느낌이 들긴 하지만 약간 어색함이 남아있다. (배경은 아직 없어 임시로 적용...)

 

아무래도 이 어색함은 잠겨있는 부분이 잔상처럼 보이지 않기 때문에 어색해 보이는 느낌이 드는 것 같아

texture의 일부 투명도를 0으로 줄였지만 조금 올려 뒤에 있는 물 배경과 형체가 자연스럽게 보이도록 수정해 보았다.

기존 그래프에서 일부를 추가하여 수정해 주었다.

우선 아랫부분과 윗부분을 나누어 아랫부분만 투명도를 조절할 수 있도록 만들어주었고

두 결과 값을 더하여 Main Texture의 알파값과 곱해주면 된다.

완성된 그래프를 저장한 뒤 다시 오브젝트를 확인해 보면 잘 적용된 모습을 볼 수 있다.

확실히 이전 방식보단 자연스러운 느낌이 남아있다.

지금 적용한 방식도 동적인 느낌이 안나다 보니 어색한 느낌이 여전히 남아있는 거 같다.

하지만 연출적인 부분에서 완전히 정해진 부분이 없어 정해지는 대로 더 나은 방식으로 자연스럽게 적용시켜보자 한다.

 

현재는 이 방식까지 밖에 생각 못했다 보니 혹시 다른 좋은 의견이 있으시다면 댓글 남겨주시면 감사하겠습니다!

지난번 C프로젝트 관련 게시글을 작성할 때 Scriptable Object를 이용하여 상태 패턴을 만드는 글을 작성해 보았었다.

https://lms0408.tistory.com/14

 

유니티 프로젝트 개발 일지 - 1 (상태 패턴)

오늘은 프로젝트 C에서 오브젝트의 상태 패턴을 구현해 볼 것이다. 이전 개인 프로젝트에서 상태 패턴을 구현할 땐,Player와 Montser 오브젝트가 있을 때 각기 다른 상태를 정의하고 같은 오브젝트

lms0408.tistory.com

오늘은 오브젝트의 상태가 있는 만큼 상태에 따른 애니메이션을 적용하고자 한다.

 

이 프로젝트에서는 오브젝트가 8방향을 가지고 있기 때문에 Animator에서 모든 클립들을 들고 와 상태 로직을 만들다 보면 지저분해 보일뿐더러 관리하기가 매우 힘들 것이다.

그렇기 때문에 오늘은 유니티에서 제공하는 블랜드 트리(Blend Tree)를 이용해 구현해보고자 한다.

 

단순히 애니메이션 클립에 관리가 힘든 부분이라면 Sub-State Machine을 만들어 해결할 수도 있다.

하지만 이러한 장점 이외에도 애니메이션을 매끄럽게 전환하는 데에도 이점이 있기 때문에

블랜드 트리를 이용하여 구현해보고자 한다.

 

유니티 Animator에 있는 블랜드 트리(Blend Tree)란?

다양한 애니메이션 클립을 하나의 상태로 묶어 특정 입력 값에 따라 혼합하여 재생하는 구조이다.

주로 3D 애니메이션을 표현할 때 이용하는 방식이지만

앞서 장점을 말했다시피 코드를 작성하지 않더라도 애니메이션을 매끄럽게 전환할 수 있다는 이점으로 인해 해당 방식을 이용하는 것이다.

 

블랜드 트리는 애니메이터에서 마우스 우클릭 시에 나오는 태그들 중 "Create State" 태그 내에서 새롭게 생성할 수 있다.

생성된 블랜드 트리를 더블 클릭하여 내부로 들어가면 기본 블랜드 트리가 만들어져 있을 것이다.

해당 블랜드 트리를 클릭하여 Inspector창을 확인해 보면 위와 같이 되어있다.

블랜드 트리에 이름은 자유롭게 수정이 가능하다.

Blend Type은 쉽게 말하자면 파라미터의 개수를 결정짓는다.

8방향을 표현하기 위해선 x, y 두 파라미터가 필요하므로 2D Simple Directional을 지정해 주면 된다.

 

이후 파라미터를 보면 두 개의 파라미터를 대입할 수 있고 기본값으로 Blend가 있을 것이다.

애니메이터에 float형 파라미터가 따로 생성되어 있지 않다면 블랜드 트리를 생성할 때 자동으로 기본 파라미터를 생성하여 적용되어 있게 된다.

 

먼저 x와 y의 값을 전달받기 위해 float형 파라미터 두 개를 생성하여 아래와 같이 지정해 주자.

이후 Motion 리스트를 추가해주면 새로운 모션을 추가할 것 인지,

새로운 블랜드 트리를 추가할 것 인지에 대한 선택이 나오며

여기선 "Add Motion Field"를 선택하여 새로운 모션을 추가해 주면 된다.

선택하여 만들어진 모션을 보면 재생할 애니메이션 클립을 넣을 수 있는 필드와

x, y의 값을 넣을 수 있는 필드에 값을 삽입할 수 있게 된다.

기본적으로 0을 기준으로 x가 양수일 땐 오른쪽, 음수일 땐 왼쪽

y가 양수일 땐 위쪽, 음수일 땐 아래쪽을 바라보는 애니메이션 클립을 재생시켜 줄 수 있도록 하고

대각선도 마찬가지로 위의 기준을 통해 값들을 삽입해 주면 완성이 된다.

모션을 추가할 때마다 파란색 포인트가 추가되는데 이 포인트는 모션의 방향을 나타내는 것이다.

그리고 파란색 포인트 외로 빨간색 포인트도 있는데

빨간색 포인트현재 파라미터 값에 따라 나타내고 있는 방향을 알려준다.

위의 사진의 경우는 현재 x와 y가 -1 값이 대입되어 왼쪽 아래의 모션을 나타내주고 있는 것이다.

 

이렇게 완성된 결과를 바탕으로 별도의 코드 없이 파라미터 값만을 수정하여

방향에 따른 애니메이션 전환이 잘 이루어지는 확인해 보면 된다.

이렇게 확인해보면 잘 적용된 것을 볼 수 있다.

적용해 보면서 제일 크게 느낀 장점은 별도의 조건과 연산 필요 없이

자동으로 자연스럽게 애니메이션을 전환해 주는 것이 정말 마음에 들었다.


참고 문서 : https://docs.unity3d.com/kr/560/Manual/class-BlendTree.html

현재 프로젝트 B에선 프로토타입을 위한 전투씬 부분의 개발을 진행하고 있는 상태이다.

전투는 다대다로 이루어지며 전투 인원이 정해져 있는 상태가 아니다.

 

전투를 진행하는 오브젝트들은 필수적으로 초기화를 위한 초기 정보가 필요하며,

해당 정보는 각 오브젝트에 적용하려 했으나 초기화 순서를 정형화하기 위해서이기도 하고

다른 이유들로 인해 한 곳에 모아둔 상태이다.

 

만약 각 오브젝트에 적용이 되어있다면 오브젝트를 생성한 이후 바로 값을 대입하고

필요없다면 오브젝트 자체를 삭제하면 되지만,

현재는 직렬화되어 있는 정보 리스트에 요소를 추가하거나 삭제를 한 이후

별도로 오브젝트들을 추가해주어야 하는 번거로움이 발생한 상태이다...

 

그래서 이번 기회에 커스텀 에디터를 통해 정보 리스트를 추가 삭제함에 따라 자동으로 오브젝트가 생성되고 삭제되는

에디터 코드를 작성해보고자 한다.

에디터 관련 코딩은 접해본 적이 없다보니 먼저 웹 검색을 통해 몇 가지를 알아보았다.

 

우선 Inspector에서 직렬화된 정보 리스트의 편집 여부를 어떻게 판단할 것 인가에 대해서 찾아보았고

SerializedProperty라는 클래스를 통해 직렬화된 정보를 받아올 수 있었다는 것을 알게 되었다.

[CustomEditor(typeof(BattleGameInitializer))]
public class CharacterCreater : Editor
{
    private SerializedProperty pInfoList;
    private SerializedProperty eInfoList;
    
    private void OnEnable()
    {
        pInfoList = serializedObject.FindProperty("playerInfos");
        eInfoList = serializedObject.FindProperty("enemyInfos");
    }
}

[CustomEditor(typeof(...)]의 선언은 특정 스크립트 타입의 Inspector를 커스터마이징 하기 위한 속성이다.

그다음 직렬화된 속성을 저장할 필드 변수를 선언해 주었다.

나는 Player와 Enemy의 정보 리스트를 따로 관리해주고 있기 때문에 두 개의 필드 변수를 선언해 주었다.

 

이후 OnEnable 메서드에서 스크립트에 정의된 필드 변수 이름으로 직렬화된 속성을 찾아서 저장해 주면 된다.

여기서 OnEnable은 MonoBehaviour에서 정의할 수 있는 OnEnable과 같은 것이다.

해당 메서드는 Inspector뷰에서 보일 때 호출된다.

 

SerializedProperty에는 다양한 프로퍼티를 통해 정보를 받아올 수 있다.

많은 프로퍼티중 나는 배열 크기만을 이용해 기존 리스트의 크기가 변경되었다는 조건식을 만들고,

리스트의 데이터가 추가되거나 삭제되었을 때 오브젝트의 인스턴스를 생성하고 삭제하는 메서드를 작성해 보자.

[CustomEditor(typeof(BattleGameInitializer))]
public class CharacterCreater : Editor
{
    private SerializedProperty pInfoList;
    private SerializedProperty eInfoList;
    
    private int prevPSize;
    private int prevESize;
    
    private GameObject playerCharPref;
    private GameObject enemyCharPref;
    
    private void OnEnable()
    {
        playerCharPref = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Prefabs/Character/PC.prefab");
        enemyCharPref = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Prefabs/Character/Enemy.prefab");

        pInfoList = serializedObject.FindProperty("playerInfos");
        eInfoList = serializedObject.FindProperty("enemyInfos");

        prevPSize = pInfoList.arraySize;
        prevESize = eInfoList.arraySize;
    }
    
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();

        if (pInfoList.arraySize != prevPSize)
        {
            ModifyBattleCharInstance(pInfoList.arraySize > prevPSize);
            prevPSize = pInfoList.arraySize;
        }

        if (eInfoList.arraySize != prevESize)
        {
            ModifyBattleCharInstance(eInfoList.arraySize > prevESize, false);
            prevESize = eInfoList.arraySize;
        }
    }

    private void ModifyBattleCharInstance(bool set, bool isPlayer = true)
    {
        var _basedName = isPlayer ? "PC" : "Enemy";
        var _charParent = GameObject.Find($"{_basedName}List").transform;
    
        // 인스턴스 추가의 경우
        if (set)
        {
            Instantiate(isPlayer ? playerCharPref : enemyCharPref, _charParent).name = $"{_basedName}{_charParent.childCount}";
            return;
        }

        // 삭제의 경우
        Destroy(_charParent.GetChild(_charParent.childCount - 1).gameObject);
    }
}

우선 이전 배열의 크기를 저장할 수 있는 필드 변수들과

오브젝트 생성을 위해 필요한 프리팹을 저장할 필드 변수들을 선언해주었다.

 

해당 필드 변수들은 이전과 마찬가지로 OnEnable 메서드에서 대입해 주었다.

이전 배열 크기값은 현재 유지하고 있는 SerializedProperty의 현재 배열 크기를 대입해 주었고,

프리팹은 프리팹이 저장되어 있는 경로를 통해 반환받은 값을 대입해 주었다.

 

그리고 조건식에 만족했을 때 실행할 메서드를 정의해 주었다.

bool형 매개 변수 "set"은 데이터 추가인지 삭제인지를 나타내주고, "isPlayer"는 Player의 여부를 나타내준다.

인스턴스를 생성할 경우 특정 부모 Transform 하위에 생성되도록 지정해 주었고,

인스턴스를 삭제할 경우 부모 Transform에서 젤 하위에 있는 인스턴스를 삭제해 주도록 코드를 작성했다.

 

삭제를 할 땐 "Destroy"가 아닌 "DestroyImmediate"를 호출해주어야 한다.

Destroy의 경우 바로 삭제가 아닌 삭제 예약 즉, 한 프레임 이후 삭제를 시키는 메서드이고

DestroyImmediate바로 삭제를 해주는 메서드이다.

Editor 편집에서는 삭제를 예약해 주는 것이 아닌 바로 삭제를 해주어야 하기 때문에 DestroyImmediate를 호출해 주는 것이다.

 

조건식은 OnInspectorGUI 메서드에서 작성해 준다.

OnInspectorGUI는 Unity Editor에서 Insepctor 창이 새로고침 될 때 호출이 되고, 주로 GUI를 그려줄 때 정의하는 메서드이다.

 

조건식 역시 이전 코드들과 마찬가지로 간단하다.

이전 배열 사이즈와 현재 배열 사이즈를 비교하여 다르다면 정의해 준 메서드를 호출해 주면 의도한 대로 인스턴스가 생성되고 삭제될 것이다.

이렇게 잘 생성되고 삭제되는 것을 확인할 수 있다!

하지만 현재 오브젝트가 프리팹 오브젝트가 아닌 개별 오브젝트로 생성되기 때문에

현재 생성된 오브젝트들은 프리팹 수정에 영향을 받지 않는다.

그렇기 때문에 리팹 오브젝트로 생성하도록 코드를 수정해 주면 된다!

//Instantiate(isPlayer ? playerCharPref : enemyCharPref, _charParent).name = $"{_basedName}{_charParent.childCount}";
PrefabUtility.InstantiatePrefab(isPlayer ? playerCharPref : enemyCharPref, _charParent).name = $"{_basedName}{_charParent.childCount}";

수정 후 테스트 해보면 위와 같이 프리팹 오브젝트로 잘 생성되고 삭제되는 것을 알 수 있다.

이 글에서 나오는 방식보다 더 좋은 방식이 있다면 댓글로 추천 부탁드립니다 :)

오늘은 프로젝트 C에서 자동 공격을 하는 오브젝트를 만들어보려고 한다.

 

해당 오브젝트는 기본적으로 이전 포스팅을 통해 작업한 상태 패턴을 기반으로 동작한다.

https://lms0408.tistory.com/14

 

유니티 프로젝트 개발 일지 - 1 (상태 패턴)

오늘은 프로젝트 C에서 오브젝트의 상태 패턴을 구현해 볼 것이다. 이전 개인 프로젝트에서 상태 패턴을 구현할 땐,Player와 Montser 오브젝트가 있을 때 각기 다른 상태를 정의하고 같은 오브젝트

lms0408.tistory.com

상태는 총 3가지로 Idle(적을 탐색하는 상태), Attack, Dead를 가진다.

오브젝트가 Attack 상태일 때 공격을 할 수 있도록 코드를 작성해보고자 한다.

 

우선 무기의 기초적인 뼈대를 작성해 볼 것이다.

public interface IWeapon
{
    public bool IsAtk { get; }
    public void ReadyAttack(float attackingTime);
    public bool UpdateAttack(Vector2 pos, Vector2 dir, float attackingTime);
}

IsAtk 프로퍼티는 무기의 공격이 준비되어 공격이 가능한 상태를 알리고,

ReadyAttack 메서드는 특정 상태일 때 매 프레임마다 호출되어 공격이 가능하게끔 준비해 주는 메서드,

UpdateAttack도 특정 상태일 때 매 프레임마다 호출되어 공격을 진행해 주는 메서드로 구현할 예정이다.

 

UpdateAttack 메서드가 bool 타입 반환형으로 선언한 이유는

해당 메서드를 호출하는 오브젝트가 가지고 있는 무기가 공격을 했다는 신호를 받아 애니메이션 클립을 재생해주기 위함이다.

 

이젠 선언한 인터페이스를 바탕으로 Weapon 클래스를 구현해보려고 한다.

[System.Serializable]
public abstract class Weapon : IWeapon
{
    [SerializeField] private float atk; // 공격력

    [SerializeField] private int maxAtkCount; // 공격 횟수
    private int atkableCount; // 현재 공격 가능한 횟수

    [SerializeField] private bool isAtk;
    [SerializeField] private float atkElapsed; // 공격 경과 시간

    [SerializeField] private float coolTime = 0.5f; // 쿨타임
    [SerializeField] private float coolTimeElapsed; // 쿨타임 경과 시간
    
    public void ReadyAttack(float attackingTime)
    {
        if ((coolTimeElapsed += Time.deltaTime) < coolTime) return;

        isAtk = true;
        atkElapsed = attackingTime;
    }
    
    public bool UpdateAttack(Vector2 pos, Vector2 dir, float attckingTime)
    {
        if ((atkElapsed += Time.deltaTime) < attckingTime) return false;

        if (--atkableCount < 0)
        {
            coolTimeElapsed = 0f;
            atkableCount = atkCount;
            isAtk = false;
            return false;
        }
    
        atkElapsed = 0f;
        Attack(pos, dir);

        return true;
    }
    
    protected abstract void Attack(Vector2 pos, Vector2 dir);
}

ReadyAttack 메서드는 Idle 상태에서 호출된다.

오브젝트들은 타겟과의 거리가 공격 범위내에 존재해야 그 자리에서 공격을 할 수 있어

이동과 같은 특수한 행동 상태 중에는 호출하지 않는다.

 

내부 구현은 매우 간단하게 되어있다.

호출이 진행됨에 따라 쿨타임 경과 시간을 업데이트해주면서 공격 준비를 시켜준다.

쿨타임이 완료되었을 땐 해당 메서드를 호출한 오브젝트가 공격 상태가 되도록

isAtk true로 변경해준다.

 

ReadyAttack을 통해 오브젝트가 Attack 상태가 되었다면 UpdateAttack 메서드를 호출하여

공격을 진행해 준다.

UpdateAttack 메서드는 atkableCount(공격 가능한 횟수)가 모두 소진되기 전까지 매 프레임 호출되게 된다.

 

매개변수 attackingTime은 한 번의 공격이 진행되는 시간이며

나는 기본값으로 공격 애니메이션 클립의 Length를 대입해 줄 것이다.

(+ ReadyAttack 메서드에서 보면 공격 경과시간을 attackingTime을 대입해 주면서 공격 상태를 맞이하게 되는데

그 이유는 공격 상태가 되었을 때 곧바로 공격을 하기 위함이다.)

 

공격 진행 시간이 초과된 이후

공격 가능한 횟수가 남아있다면 다시 공격을 진행해 준다.

만약 공격 가능한 횟수가 남아있지 않다면 공격 준비 상태가 되도록 isAtkfalse로 변경해 주게 된다.

 

이 Weapon 클래스의 뼈대를 바탕으로 나는 근거리 무기와 원거리 무기로 나누어 상속을 해주었다.

현재는 원거리 무기를 테스트하고자 하니 간단하게 투사체를 발사하는 코드를 구현하면 다음과 같다.

[System.Serializable]
public class LongRangeWeapon : Weapon
{
    public Rigidbody2D pref;
    protected override void Attack(Vector2 pos, Vector2 dir)
    {
        var _obj = GameObject.Instantiate(pref, pos, 
            Quaternion.Euler(0f, 0f, Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg - 90f));
        _obj.velocity = dir * 5f * Time.fixedDeltaTime;
    }
}

 

이제 위의 Weapon 로직이 잘 돌아가는지 확인하기 위해 투사체를 발사하는 대포 오브젝트에 적용을 해보자.

public interface ICannonAction : IIdleAction, IAttackAction, IDeadAction { }

public class CannonController : MyObject, ICannonAction
{
    [SerializeField] private LongRangeWeapon myWeapon;
    [SerializeField] private Animator _animator;
    
    public Quaternion originRot;
    public Transform Target;
    
    // Something...
    
    public void Idle()
    {
        Quaternion _targetRot = Quaternion.identity;
        if (Target == null) _targetRot = originRot;
        else
        {
            // Cannon Position에서 타겟 Position까지의 벡터를 구해 각도를 변환
            var _dir = (weapon.Target.Position - (Vector2)weapon.Transform.position).normalized;
            var _axis = Mathf.Atan2(_dir.y, _dir.x) * Mathf.Rad2Deg;
            _targetRot = Quaternion.Euler(0f, 0f, _axis);

            myWeapon.ReadyAttack(1f);
        }
        
        var _rotSpeed = 10f;
        transform.rotation = Quaternion.Lerp(transform.rotation, _targetRot, Time.deltaTime * _rotSpeed);
    }
    
    public void Attack()
    {
        if (!myWeapon.UpdateAttack(transform.position, transform.up, 1f)) return;
        // Weapon이 공격을 했다면 애니메이션 재생
        _animator.SetTrigger("Attack");
    }
    
    // Something...
}

위 코드는 CannonController의 핵심적인 코드의 일부분이며, 위에서 설명한 설계대로 구현이 된 상태이다.

추가적인 부분은 타겟이 존재하지 않을땐 항상 처음에 셋팅한 rotation을 바라보고,

타겟이 존재하고 공격이 준비되고 있는 상태에선 타겟을 바라보도록 연산해보았다.

 

해당 스크립트 컴포넌트가 적용된 오브젝트를 적절하게 배치하여 테스트 해보면 문제없이 잘 동작하는 모습을 볼 수 있게 된다.

오늘은 프로젝트 B에서 진행한 곡선 UI를 그리는 작업을 공유하고자 한다.

우선 Line을 그리기 위해 유니티 Effects의 Line 오브젝트를 생성해주도록 하자.

오브젝트를 생성한다면 Line Renderer 컴포넌트가 포함되어 있을 것이다.

여기서 우리가 중점으로 봐야할 부분은 Positions 속성이다.

대충 값을 대입하다보면 해당 Position들은 점들의 위치이며 두개의 점을 이어 Line을 그려준다는 것을 알 수 있다.

 

우선 한 정점을 기준으로 마우스 커서 까지 이어주는 직선 형태의 Line을 그려보도록 하자.

public class StraightLine : MonoBehaviour
{
    [SerializeField] private LineRenderer line;
    
    void Start()
    {
        line.positionCount = 2;
        line.SetPosition(0, Vector2.zero);
    }

    void Update()
    {
        var _mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
        line.SetPosition(1, _mousePos);
    }
}

직선은 정점 두개로 이루어지기 때문에 positionCount를 2로 초기화 해주었다.

나는 시작점을 (0, 0)으로 설정해주었고 마우스 position을 월드 좌표로 변환하여 끝점에 대입해주었다.

 

하지만 내가 구현해야하는 방식은 직선이 아닌 곡선이다보니 위의 방식과는 다르게 가야한다.

곡선을 구현하기 위한 아이디어를 찾기 위해 인터넷 검색을 통해 알아본 결과 베지어 곡선을 이용해야한다는 것을 알게 되었다.

 

베지어 곡선이란? n개의 조절점을 가지고 선형 보간을 통해 곡선 형태를 이루는 점들의 집합을 구하는 공식이다.

베지어 곡선에 대한 자세한 내용은 아래의 블로그를 참고하자!

https://ko.javascript.info/bezier-curve

 

베지어 곡선

 

ko.javascript.info

해당 프로젝트에서 그리는 곡선은 이차 함수의 모양 형태로 그려야하기 때문에 조절점이 3개인 2차 베지어 곡선의 식을 참고하여 구현해보았다.

https://namu.wiki/w/%EB%B2%A0%EC%A7%80%EC%97%90%20%EA%B3%A1%EC%84%A0

위의 식을 참고하여 코드로 나타내면 다음과 같다.

public int segmentCount = 20;
public float controlPointY = 1f;

private Vector2 CalculateQuadraticBezierPoint(Vector2 p1, Vector2 p2, Vector2 p3, float t)
{
    var _u = 1 - t;
    var _tt = t * t;
    var _uu = _u * _u;

    Vector2 _point = _uu * p1;
    _point += 2 * _u * t * p2;
    _point += _tt * p3;

    return _point;
}

private void DrawBezierCurve(Vector2 endPos)
{
    var _positions = new Vector3[segmentCount + 1];
    line.positionCount = segmentCount + 1;

    var _p1 = Vector3.zero;
    var _p3 = endPos;

    var _p2 = new Vector3((_p1.x + _p3.x) * 0.5f, _p1.y + controlPointY, 0f);

    for (int i = 0; i < segmentCount + 1; i++)
    {
        float _t = (float)i / segmentCount;
        _positions[i] = CalculateQuadraticBezierPoint(_p1, _p2, _p3, _t);
    }

    line.SetPositions(_positions);
}

p1과 p3는 각각 시작점과 끝점으로 대입해주었으며 p2 조절점의 경우 p1과 p3의 중점으로 두었다.

점의 갯수가 많을 수록 더 부드러운 곡선을 연출할 수 있으므로 20개 정도로 임의의 값을 넣어 실행해보았다.

실행한 결과 만족스러운 결과가 나왔다.

여기서 조절점의 위치를 조절해서 원하는 곡선 형태로 만들어준다면 더 이쁘게 나올거 같다.

부족함이 많은 코드인만큼 더 좋은 방식이 있다면 댓글로 추천해주시면 감사하겠습니다!

오늘은 A 프로젝트에서 같이 개발하는 팀원에게 이슈 관련으로 연락이 왔다.

유니티 Editor Game뷰에서 스프라이트의 레이어 순서가 적용되지 않는 문제가 발생한 것이다.

 

오브젝트의 전체 몸체를 개별로 나누어서 파츠 형식으로 리소스를 제작한 상태이고

각 파츠별 앞서 있는 sprite 일수록 Order in Layer 값을 조절한 상태였다.

원래는 왼쪽과 같이 설정한 Layer의 순서대로 Rendering 되어 이쁘게 나와야 하는 오브젝트가...

오른쪽과 같이 Rendering 순서가 잘못되어 이쁘게 나오지 않는 현상이 발생한 것이다

설정한 Layer 순서가 적용이 되지 않았는지 확인하기 위해 Frame Debugger를 통해 Draw 되는 순서를 확인해 보았지만

설정한 순서대로 잘 Draw가 되고 있었다...

 

해당 오브젝트는 2.5D의 분위기를 연출하는 씬에서 사용되기 때문에 씬에서 Game뷰를 랜더링 해주는 카메라 projection이 perspective로 설정되어 있어 급한 대로 z축의 값을 일부 조정해서 렌더링 순서를 맞췄다.

 

하지만 z축 값이 동일한 상태에서 Layer 값만을 조절해서 해결할 수 없는 부분이 매우 의문이 들었다.

그래서 오늘은 왜 Layer 값만으론 해결할 수 없었는지

z축 값을 조절하지 않고도 랜더링 순서 대한 문제를 해결할 수 있는 방법에 대해 알아보고자 한다.


여러 방법을 시도해 보면서 문제에 대한 삽질을 한 끝에 무엇 때문에 해당 문제가 발생되었는지 알게 되었다.

결론적으로 말하자면 Z-fighting이 발생해서 위와 같은 문제가 발생한 것이다.

 

https://drehzr.tistory.com/1461

Z-fighting이란 두 오브젝트가 동일한 깊이를 공유할 때 Z-buffer의 정밀도 부족으로 인해 렌더링 결과가 깜빡이거나 섞이는 현상이다.

 

Orthographic모드는 카메라와 오브젝트 간의 실제 거리 대신 직선 거리를 기반으로 선형적으로 계산되어 Z-buffer의 정밀도가 높아지는 데에 비해

Perspective모드는 실제 거리를 기반으로 멀어질수록 Z-buffer의 정밀도가 떨어지게 된다.

 

그렇기 때문에 오브젝트의 rotation이 카메라를 바라보는 각도라면

Z-buffer의 정밀도가 높아져 Z-fighting의 발생 빈도가 줄어들면서 Z값을 수정하지 않더라도 위의 문제를 해결할 수 있을 것이다.

 

하지만 오브젝트를 항상 카메라를 바라보도록 하는 방식은 현재 게임 방향성과 맞지 않는 부분이기 때문에 다른 방식을 택하여 이 문제를 해결했다.

현재 Z-fighting이 발생하는 오브젝트의 Depth를 읽지 않도록 설정한다면 rotation을 수정하지 않고도 Layer 순서대로 Game뷰에 랜더링 될 것이다.

기존 사용하던 Shader에서 Depth Write를 Disabled로 변경해주면 깊이를 읽지 않을 수 있다.

해당 설정으로 변경한 결과 기존 의도했던 순서대로 Sprite들이 랜더링 되는 모습을 볼 수 있다.

 


참고 블로그 : https://rito15.github.io/posts/unity-transparent-stencil/

오늘은 프로젝트 A에서 사용할 팝업 UI 컨트롤러를 만들어볼 생각이다.

 

팝업이 단순히 하나씩만 표시되게 한다면 컨트롤러가 굳이 필요하지 않을거라 생각했는데

게임의 방향성을 보았을 때 여러 팝업을 열기로 결정이 되어 제작하기로 했다.

 

우선 기본적으로 새로운 팝업이 열렸을 때 이전 팝업의 상호작용은 불가능해야하며,팝업을 닫을 땐 먼저 열린 팝업이 제일 마지막에 닫혀야한다.

이러한 점을 고려했을 때 후입선출의 특징을 가지고 있다는 것을 알 수 있다.

그렇기 때문에 후입선출의 자료구조인 Stack을 활용하여 컨트롤러를 제작해보았다.

 

우선 한 컨트롤러로 모든 PopUp들을 컨트롤하기 위해선 캡슐화가 필요했다.

public interface IPopUp
{
    public void Active();
    public void UnActive();
    public void ActiveRayTarget();
    public void UnActiveRayTarget();
}

ActiveUnActive를 통해 팝업 오브젝트의 Active 상태를 컨트롤 하고 ActiveRayTargetUnActiveRayTarget을 통해 팝업 오브젝트의 상호작용을 컨트롤 해주기 위해 이렇게 4가지의 메서드를 캡슐화 해보았다.

그리고 정의된 인터페이스를 통해 팝업 컨트롤이 필요한 클래스에 적절히 인터페이스를 구현해주었다.

public class ExplainCanclePopUp : MonoBehaviour, IPopUp
{
    // something...
    public void Active()
    {
        StartCoroutine(FadeIn());
    }
    
    public void UnActive()
    {
        StartCoroutine(FadeOut());
    }
    
    public void ActiveRayTarget()
    {
    	img.raycastTarget = true;
    }
    
    public void UnActiveRayTarget()
    {
    	img.raycastTarget = false;
    }
    
    private IEnumerator FadeIn()
    {
        // Something...
    }
    
    private IEnumerator FadeOut()
    {
    	// Something...
    }
}

나는 취소 상호작용을 설명해주는 PopUp UI에 인터페이스를 구현해주었다.

팝업이 열릴 땐 Fade In이 되고, 팝업이 닫힐 땐 Fade Out이 되도록 구현해주었고

raycastTarget의 값을 변경하여 상호작용 컨트롤을 구현해주었다.

 

다음은 위와 같이 구현된 여러 개의 팝업 UI를 컨트롤 해줄 PopUpController를 구현해볼려고 한다.

public class PopUpController : MonoBehaviour
{
    private Stack<IPopUp> popUpStack = new Stack<IPopUp>();
    
    public void PushPopUp(IPopUp popUp) { }
    public void PopPopUp() { }
}

앞서 설명했듯이 후입선출의 특징으로 팝업 리스트를 관리해주기 위해 Stack 자료구조로 정의해주었다.

또한 팝업을 삽입, 삭제를 할 수 있는 간단한 메서드도 정의해주었다.

삽입에 대한 구현은 다음과 같다.

public void PushPopUp(IPopUp popUp)
{
    if (popUpStack.Count > 0)
    {
        var _top = popUpStack.Top();
        
        if (_top.Equals(popUp)) return;
        _top.UnActiveRayTarget();
    }
    
    popUpStack.Push(popUp);
    popUp.Active();
    popUp.ActiveRayTarget();
}

public static class ExtensionMethods
{
    public static T Top<T>(this Stack<T> values)
    {
	var _top = values.Pop();
   	values.Push(_top);
   	return _top;
    }
}

Stack에 팝업을 Push하기전 열려있는 팝업이 존재하는지 체크를 해주고 있다면 해당 팝업의 상호작용을 하지 못하도록 제어해준다.

이후 새로 열린 팝업을 리스트에 Push 해주고 해당 팝업을 Active하고 상호작용이 가능하도록 해준다.

 

+ C# Stack 자료구조는 Pop 메서드를 통해 마지막에 대입된 값을 반환 받을 수 있기 때문에

데이터의 삭제를 원치 않을 경우엔 번거롭게 다시 추가해주는 작업을 거쳐야 된다.

그렇기 때문에 나는 기존 Stack 클래스의 확장 메서드로 Top을 정의해주었다.

 

삭제에 대한 구현은 다음과 같다.

public void PopPopUp()
{
    if (popUpStack.Count.Equals(0))
    {
        PushPopUp(optionPopUp);
        return;
    }
    
    var _pop = popUpStack.Pop();
    _pop.UnActiveRayTarget();
    _pop.UnActive();
    
    popUpStack.Top().ActiveRayTarget();
}

private void Update()
{
    if (Input.GetKeyDown(KeyCode.Escape)) PopPopUp();
}

삭제 구현도 삽입과 마찬가지로 간단하다.

삭제에선 닫히는 팝업을 제어해주고 닫힌 팝업 이전에 있는 팝업을 활성화 시켜주면 된다.

 

이 프로젝트에선 팝업 닫기 버튼 이외에도 ESC키를 이용하여 팝업을 닫을 수 있도록 하였기 때문에

Update문에서 Input을 체크해주었고,

아무런 팝업이 열려있지 않을 때 ESC 키를 상호작용하면 옵션 팝업이 열리도록 구현해주었다.

 

이렇게 하나의 팝업 컨트롤러를 통해 관리하니 비교적 코드도 많이 쓰지 않고 구현한 것 같다.

더 좋은 방식이 있다면 댓글로 추천해주시면 감사하겠습니다!

오늘은 프로젝트 C에서 오브젝트의 상태 패턴을 구현해 볼 것이다.

 

이전 개인 프로젝트에서 상태 패턴을 구현할 땐,Player와 Montser 오브젝트가 있을 때 각기 다른 상태를 정의하고 같은 오브젝트 끼리는 해당 상태 인스턴스를 공유한 방식이었다.같은 상태를 가지는 오브젝트가 개별로 상태 인스턴스를 가지고 있는건 메모리적으로 낭비가 되는 부분이기 때문에각 상태를 CashData로 저장하여 공유가 되도록 했다.

 

이번에는 이전 방식과 로직은 비슷하되 Scriptable Object를 활용하여 제작해보기로 결정했다.

 

스크립트는 크게 세 분류로 나누었으며,각 상태를 컨트롤 해주는 State Machine과 해당 상태로 변경할 수 있는 지에 대한 여부를 판단하는 Decision,해당 상태의 Action이 정의된 State로 나누었다.

public abstract class StateDecision : ScriptableObject
{
    public abstract bool Decision(MyObject obj);
}

Decision은 bool 반환형 메서드로 정의하여 해당 상태로 변경이 가능한지에 대한 여부를 판단할 수 있도록 정의해주었다.

public abstract class State : ScriptableObject
{
    public StateDecision decision;

    public virtual void Enter(MyObject obj) { }
    public virtual void Action(MyObject obj) { }
    public virtual void Exit(MyObject obj) { }
}

State는 StateDecision 필드 변수 하나와 메서드 3가지로 정의하였다.

decision은 앞서 정의한 StateDecision을 가지는 필드 변수이다. 각 상태에 대응되는 Decision을 대입할 것 이다.

Enter는 해당 상태로 변했을 때 초기화가 이루어지는 부분이고, Action은 해당 상태에서 매 프레임마다 Update되는 메서드이다.

Exit는 해당 상태가 종료되었을 때 한번 호출되는 메서드이다.

public class GeneralStateMachine : ScriptableObject
{
    [SerializeField] private List<State> states;

    public void TransState(MyObject obj, ref State curState)
    {
        foreach (var _state in states)
        {
            if (_state.decision == null || _state.decision.Decision(obj))
            {
                ChangeState(obj, ref curState, _state);
                break;
            }
        }
    }

    public void UpdateState(MyObject obj, State curState)
    {
        curState.Action(obj);
    }

    private void ChangeState(MyObject obj, ref State curState, State newState)
    {
        if (curState.Equals(newState)) return;

        curState.Exit(obj);
        newState.Enter(obj);
        curState = newState;
    }
}

StateMachine은 매 프레임마다 호출되어 상태를 바꾸어주는 TransState와 매 프레임마다 현재 상태의 Action을 호출해주는 UpdateState로 나누었다.

 

각 상태는 List를 통해 저장하며 순서가 젤 앞설수록 상태 전환의 우선 순위를 가질 수 있도록 설계했다.

 

현재 상태는 각 오브젝트 별로 가지고 있어(현재 상태는 고유의 값이므로) 매개변수를 통해 현재 상태를 전달해준다.

이때 현재 상태를 매개 변수로 전달할 때 값 형식으로 전달되기 때문에 반드시 ref 키워드를 붙여 참조 형식으로 전달해주어야 현재 상태의 값이 반영된다.

 

이렇게 뼈대는 완성이 되었고 이제 살만 붙이는 작업만 해준다면 멋진 상태 패턴을 만들 수 있다.

 

이전 방식에선 Controller에서 상태에 대한 행동을 정의하지 않고 상태 클래스 내에서 행동을 모두 정의했다.

Controller의 코드 다이어트가 되어 조금 더 깔끔해 보이지 않을까 하는 생각에 해당 방식으로 작성한 것이었다.

 

하지만 이번엔 조금 더 상태를 범용성있게 사용하기 위해 각 Controller에 상태에 대한 행동을 정의하고 해당 메서드를 각 상태에서 호출할 수 있게끔 설계하였고 먼저 캡슐화 작업을 해주었다.

#region State
public interface IIdleState
{
    public bool Idleable { get; }
}
public interface IMoveState
{
    public bool Moveable { get; }
}
public interface IAttackState
{
    public bool Attackable { get; }
}
public interface IHitState
{
    public bool Hitable { get; }
}
public interface IDeadState
{
    public bool IsDead { get; }
}
#endregion

#region Action
public interface IIdleAction
{
    public void Idle();
}
public interface IMoveAction
{
    public void Move();
}
public interface IAttackAction
{
    public void Attack();
}
public interface IHitAction
{
    public void Hit();
}
public interface IDeadAction
{
    public void Dead();
}
#endregion

캡슐화는 Decision에 필요한 것과 Action을 위해 필요한 것으로 나누어 정의했다.이후 필요한게 있다면 추가적으로 캡슐화를 할 예정이다.

 

이렇게 정의된 interface를 바탕으로 각 State와 Decision을 정의해주었고, 아래는 Move 상태에 대한 코드이다.

public class MoveDecision : StateDecision
{
    public override bool Decision(MyObject obj)
    {
        return (obj as IMoveState).Moveable;
    }
}

public class MoveState : State
{
    public override void Enter(MyObject obj)
    {
    }
    public override void Action(MyObject obj)
    {
        (obj as IMoveAction).Move();
    }
    public override void Exit(MyObject obj) 
    {
    }
}

매개 변수를 통해 전달 받은 오브젝트를 캐스팅하여 메서드를 호출하는 간단한 코드이다.

 

이렇게 정의된 스크립트를 바탕으로 Decision과 State를 만들어주었고, 해당 State에 대응되는 Decision을 대입해주었다.

다른 상태들 역시 필요하다면 이렇게 추가하여 관리할 수 있다.

 

이렇게 몬스터에 필요한 각 상태들을 정의하고 우선순위에 맞게 각 상태들을 List에 대입한 모습이다.

 

이 방식은 Editor상에서 좀 더 직관적으로 상태를 관리할 수 있다는 점이 아주 좋은 부분인 것 같다.

처음 시도인만큼 완벽한 설계 방식은 아니지만 문제없이 돌아간다는 점에서 큰 만족감을 가지게 되었다.

현재 유니티를 활용하여 게임 프로젝트에 참가하고 있는 상태이다.

총 3개의 프로젝트를 진행하고 있으며, 개발 일지를 작성할 땐 각각의 프로젝트 이름을 A, B, C라고 지칭해서 포스팅을 할려고한다.

 

3개의 프로젝트 모두 내년 상반기내에 출시할 예정이며

출시 하기전까지 내가 개발한 기록을 포스팅할 예정이다. (일지인 만큼 매일 하루에 하나씩 올릴 수 있도록 노력할려고 한다!)

 

처음에는 이러한 개발일지를 작성하고자 하는 마음이 없었다. 

이전까지는 가벼운 내용들은 머릿속에만 넣고 기록을 하지 않을려고 했지만,

시간이 지나면 휘발될 가능성이 높기 때문에 기록하고자 결심했다!

 

앞으로 작성될 개발 일지 내용에 대해 많은 관심 부탁드립니다 :)

Asset이란?

유니티 프로젝트에서 사용하는 텍스처, 오디오, 스크립트, 애니메이션 등의 모든 데이터를 의미한다.

이러한 데이터들은 보통 외부에서 가져온 파일인데,

해당 파일들을 Unity에서 처리할 수 있는 형식으로 Import하여 사용할 수 있는 것이 'Asset'이다.

 

이번 게시글에서는 유니티에서 제공하는 기능을 통해 어떻게 에셋을 관리하고 어떤 부분에서 차이가 나는지에 대해 알아보고자 한다.

 

기본적으로 유니티에서 프로젝트를 빌드할 경우

각 씬(Scene)별로 패키징되어 씬을 로드할 때 해당 씬에 필요한 모든 에셋이 메모리에 로드된다.

Inspector창에서 Asset을 직접 참조

빌드 과정에서 에셋이 씬에 포함되지 않았거나, 위의 사진과 같이 Inspector 창에서 참조되지 않은 경우는 빌드 파일에 포함되지 않는다.


Resources

에셋을 동적으로 사용하기 위해서는 메모리에 올라와 있는 에셋을 참조하고 있는 매개체가 필요하다.

유니티에선 이러한 참조 매개체 역할을 해주는 Resources 시스템이 있다.

 

해당 시스템의 경우

'Asset' 폴더내에 'Resources' 폴더를 포함할 시 빌드 할 때 해당 폴더 내에 있는 모든 에셋을 패키징하여

리소스가 필요한 순간에 모든 에셋이 메모리에 로드되는 방식이다.

한 곳에 묶어진 Asset들

모든 에셋을 메모리에 로드하는 방식이기 때문에 일반적으로 다음과 같은 단점을 가진다.

  1. 세분화된 메모리 관리가 어려워 짐
  2. 애플리케이션 시작 시간과 빌드 시간이 길어짐
  3. 특정 플랫폼에 사용자 지정 콘텐츠를 제공하는 프로젝트의 기능을 저하시키고 점진적인 콘텐츠 업그레이드의 가능성을 제거

모든 에셋을 한 번에 패키징하기 때문에 메모리를 세분화로 관리하기 어려워지며

에셋이 많아질 수록 그만큼 시작과 빌드 시간이 길어지게 된다.또한 플랫폼 별 불필요한 에셋마저 빌드하기 때문에 프로젝트의 기능이 저하되며추가 콘텐츠 제작 시 새로운 에셋들만 빌드하기가 어렵기 때문에 업그레이드 가능성을 제거하는 것과 마찬가지이다.

 

유니티에서는 이러한 문제점들로 인해 Resources 시스템을 사용하는 것을 권장하지 않는다.

 

다만 리소스 폴더는 사용하기 쉬워 빠르게 프로토타입을 제작할 경우 사용하고,프로젝트가 정식 프로덕션 단계로 넘어갈 땐 제거하는 방식으로 사용하기도 한다.또한 일반적으로 프로젝트의 lifetime 동안 계속 필요한 에셋의 경우 사용한다.


AssetBundle

AssetBundle은 Resources 시스템의 단점을 보완하여 나온 것이다.

 

AssetBundle은 게임을 논리적 블록으로 분할할 수 있어

게임 빌드의 크기를 줄이면서 온디맨드(On-demand) 방식으로 콘텐츠를 제공하고 업데이트할 수 있다.

에셋 번들에는 프리팹, 머테리얼, 텍스처, 오디오 클립, 씬 등을 비롯한 모든 종류의 에셋을 포함할 수 있지만 스크립트는 포함할 수 없다.

종료 별로 묶어진 Asset들

AssetBundle은 일반적으로 두 부분으로 구성되어있다.

헤더(Header)

헤더에는 에셋 번들이 빌드될 때 유니티로부터 받은 많은 양의 정보가 들어있는 곳이다.

에셋번들의 식별자(Version에 대한 정보 같은), 압축 방식, 매니페스터 (manifest) 등과 같은 정보가 있다.

매니페스트에는 에셋의 이름을 키로 삼아 해당 에셋이 데이터 세그먼트에 위치한 바이트 인덱스를 저장하는 검색 테이블이다.

데이터 세그먼트(Data Segment)

직렬화 된 에셋의 실제 데이터가 저장되는 부분이며 위치를 찾을 수 있는 인덱스를 제공한다.

 

보통 검색의 경우 대부분 균형 검색 트리(balanced search tree)를 사용하며,Windows와 OSX계열(IOS포함) 플랫폼은 레드 블랙트리를 사용한다.

 

또한 그룹화하여 저장을 할 때 압축 방식을 선택할 수 있는데, 그 방식 3가지로 나뉜다.

  • LZMA : 직렬화된 모든 에셋에 대한 전체 바이트 배열이 압축되는 방식
  • LZ4 : 개별 에셋의 바이트가 개별적으로 압축되는 방식
  • 비압축 : 원시 바이트 스트림으로 유지

비압축 방식으로 빌드할 경우 애플리케이션을 다운로드 할 때 에셋 번들의 크기를 추가 다운로드 하므로 시간 소요가 제일 길다.

하지만 다운로드 이후 에셋을 빠르게 로드할 수 있다.

 

일반적으로 압축을 할 때 압축 방식을 NONE으로 빌드하게 되면

LZMA(Lempel-Ziv-Markov chain Algorithm) 방식으로 압축이 이루어진다.

에셋 번들 내에 있는 전체 에셋을 한 번에 압축하기 때문에 파일의 크기가 가장 작아진다.

하지만 에셋을 로드할 경우 전체 압축을 해제하기 때문에 그만큼 압축 해제가 느리다.

 

Unity 5.3 이후 버전부터는 LZ4 압축 방식이 등장하였다.

청크 기반 알고리즘을 사용하여 에셋을 부분적 또는 "청크" 단위로 로드될 수 있도록 한다.

그렇기 때문에 번들 내의 모든 에셋을 한꺼번에 압축 해제하지 않아 시간이 많이 소요되지 않는다.

하지만 LZMA 압축 방식에 비해 압축 크기는 큰 편이다.

 

특정 상황에 맞게 압축 방식을 선택하여 빌드를 한다면

사용자에게 있어 더 나은 경험을 전달할 수 있기 때문에 압축 방식에 대한 간단한 이해는 필수라고 생각한다.

더보기

유니티는 성능 최적화를 위해 LZMA로 압축된 에셋 번들은 다시 LZ4 압축으로 재압축되어 로컬 파일 시스템에서 캐시된다.

AsserBundle WorkFlow

빌드한 에셋 번들을 CDN(파일 저장 서버 및 클라우드)에 올린다.

CDN에 UnityWebRequest를 요청하여 다운받아 캐싱한다. (캐싱이 되면 다시 실행할 때는 다운할 필요가 없다.)

클라이언트에서 에셋 번들을 통해 에셋을사용한다.

 

AssetBundle도 좋은 장점을 지닌 반면 단점 또한 존재한다.

의존성

하나의 AssetBundle에 포함된 에셋이 다른 AssetBundle에 의존하고 있는 경우를 말한다.

의존성의 문제

의존성의 문제로 인해 하나의 리소스를 각 번들에 적재함으로서 동일한 에셋이 중복되어 메모리 낭비의 원인이 된다.

의존성의 문제

또한 위와 같이 세분화하여 번들을 나누더라도 A와 B Object는 Image를 의존하기 때문에

C번들에 있는 Image를 사용하기 위해 이외의 모든 에셋들을 메모리에 올려야 하므로 메모리 낭비의 원인이 된다.

 

이러한 의존성 문제를 해결하기 위해 개발자가 따로 관리해줄 필요가 있다.

관리가 간단하고 할 양이 많지 않다면 괜찮겠지만

프로젝트 규모에 따라 관리가 쉽지 않기 때문에 문제가 된다.

경로

에셋을 로드하기 위해서는 경로와 파일명을 통해 해당 에셋이 들어있는 번들과 에셋을 찾을 수 있는데

해당 경로와 파일명을 string으로 매칭시켜 수정에 매우 취약하다.

 

한 에셋의 이름이 수정될 경우 해당 에셋을 로드하는 코드(파일명)도

일일이 다 수정해야할 뿐더러

문자열 오타로 인해 매칭이 끊어져버릴 수 있기 때문에 번거로움이 많다.


Addressable

Addressable은 AssetBundle을 토대로 설계된 시스템으로완전히 새롭게 만들어진 것이 아니라 AssetBundle의 편의성을 개선하기 위해 등장한 시스템이다.

카탈로그(Catalog)

AssetBundle에 manifest가 있다면 Addressable에는 Catalog가 있다.

Catalog는 Addressable의 번들과 함께 생성되는 것으로

Asset의 Address(Group 및 Lable 정보)와 Asset의 매핑 정보가 기재된 파일이다.

또한 버전이 붙어서 생성되기 때문에 번들 자체의 버전 관리도 된다.

 

Addressable이 AssetBundle의 단점을 보완하여 나온 만큼 다음과 같은 보완을 통해

이전의 단점을 보완했다.

의존성

기존 AssetBundle의 경우 의존성으로 인해 메모리 낭비의 원인이 되는 문제가 있었다.

Addressable의 경우 번들 단위로 의존성이 묶이지 않는다.

의존성 문제 해결

서로 다른 번들이라도 의존성이 있다면  API에서 일괄 관리가 되기 때문에

기존 의존성에 의한 중복이나 수동관리가 해결됐다.

경로

기존 AssetBundle의 경우 Asset을 로드하기 위해

해당 Asset이 저장된 Path와 코드에서 사용하는 Path 값을 똑같이 맞춰야한다.

그렇기 때문에 한쪽이 변경된다면 다른 쪽도 변경해줘야하는 번거로움이 생겼다.

 

Addressable은 이름에서 볼 수 있듯이 Address(참조) 방식을 사용한다.Asset의 Path를 참조하기 때문에 Path를 참조하는 값만 처음에 맞춰서 작업해 놓는다면경로가 바뀌거나 에셋의 이름을 변경하더라도 별도로 수정할 번거로움이 없어졌다.

번들 로드 & 언로드

이전 Bundle의 경우 Asset을 로드하기 위해 Bundle을 수동으로 로드하여야 했고

또한 메모리 관리를 위해 쓰지 않는 Asset의 경우 수동으로 언로드 해야했다.

 

하지만 Addressable API의 경우 번들을 로드하고 에셋을 로드하는 과정이 한번에 묶여있기 때문에

기존에 보다 간단하게 구현된다.

즉, 에셋을 요청하면 자동으로 번들을 로드해준다는 뜻이다.

 

또한 언로드하는 과정도 자동으로 이루어지는데

이는 참조 카운팅 방식을 통해 메모리 관리를 효율적으로 도와준다.


마무리

Asset을 잘 관리하여야 메모리 효율을 높일 수 있으며

애플리케이션의 크기 또한 간소화 시킬 수 있다.

 

특히 모바일 애플리케이션의 경우 PC와는 달리 배포 가능한 애플리케이션의 크기가 상당히 제한되어 있기 때문에

애플리케이션의 크기를 간소화할 필요가 있다.

 

이렇게 Asset들을 관리하는 방식과 원리에 대해 자세히 알고 적용한다면

효율적인 빌드가 가능해진다.


참고 블로그

https://gus6615.tistory.com/m/85

https://medium.com/pinkfong/unity-addressable-asset-%EB%A5%BC-%EC%99%9C-3017f3fa2edc

참고 문서

https://docs.unity3d.com/kr/2021.2/Manual/AssetBundles-Building.html

+ Recent posts