이전 포스팅에서 정리했었던 게임 AI 구현을 위한 디자인 패턴인 FSM의 단점이었던 상태가 많아질 수록 상태 전이 조건이 매우 복잡해진다는 단점을 해결하고 복잡한 행동 로직을 구현하기 위해 Behavior Tree를 사용한다.

 

BT는 논리적인 트리 구조를 사용하며 루트 노드에서 시작해 깊이 우선 탐색(DFS)으로 자식 노드를 평가하고 평가 결과를 다시 부모 노드에게 반환하는 구조를 가진다.

 

평가 결과는 일반적으로 아래 3가지 상태 중 하나를 반환한다.

1. Success : 노드가 조건을 만족함

2. Failure : 노드가 조건을 만족하지 않음

3. Running : 노드의 조건을 검사가 아직 진행중

* Running 상태는  조건의 평가 혹은 자식 노드들의 평가가 한 프레임만으로 처리될 수 없는 경우 게임의 흐름을 방해하지 않고 이를 다음 프레임에 다시 체크하기 위해 사용된다.

 

BT 에서 각 노드 타입은 다음과 같이 구분할 수 있다.

1. Action Node : 에이전트가 수행할 수 있는 어떤 종류의 행동도 될 수 있는 노드이다. 게임 캐릭터 AI의 경우 걷기, 공격하기, 적 탐지 등 행동에 대한 디테일한 구현이 정의되어야 한다. 중요한 점은 자식이 없는 계층의 가장 마지막 노드인 리프 노드(Leaf Node)이어야 한다는 점이다.

2. Composite Node : 합성 노드라고도 하며 하나 이상의 자식을 가지며 하나 이상의 자식을 수행하는 노드이다. 자식 노드들의 평가 결과를 통해 자신의 상태를 결정한다. 대표적인 합성 노드의 종류는 다음과 같다. 

  1. 시퀀스 노드(Sequence node): 시퀀스 노드는 자식 노드들을 순서대로 실행한다. 하나의 자식 노드가 실패할 경우, 다음 자식 노드를 실행하지 않고 실패를 반환하며. 모든 자식 노드가 성공하면 성공을 반환한다.
  2. 선택자 노드(Selector node): 선택자 노드는 자식 노드들을 순서대로 실행한다. 하나의 자식 노드가 성공할 경우, 다음 자식 노드를 실행하지 않고 성공을 반환한다. 모든 자식 노드가 실패하면 실패를 반환한다.
  3. 병렬 노드(Parallel node): 병렬 노드는 자식 노드들을 병렬로 실행한다. 모든 자식 노드가 성공하면 성공을 반환한다. 모든 자식 노드가 실패하면 실패를 반환한다. 자식 노드 중 하나 이상이 Running 상태인 경우, Running을 반환한다.

3. Decorator Node : 자식 노드에 새로운 동작을 추가하거나 변경하는 역할을 하는 노드이다. 데코레이터 노드에는 다음과 같은 종류가 있다.

 

  1. 인버터 노드(Inverter node): 자식 노드를 실행하고, 자식 노드가 실패하면 성공을 반환하고, 자식 노드가 성공하면 실패를 반환한다. 따라서, 자식 노드의 결과를 반대로 반환하는 데 사용된다.
  2. 리피터 노드(Repeater node): 리피터 노드는 자식 노드를 지정된 횟수만큼 혹은 무한히 반복하게 한다.
  3. 리미터 노드(Limiter node): 리미터 노드는 자식 노드를 지정된 시간 동안만 실행한다. 시간이 지난 후에는 Running 상태를 반환한다. 

위 내용을 바탕으로 BT를 구현해보자.

 

public enum BTNodeState
{
    Success,
    Failure,
    Running
}

먼저 각 노드의 평가 결과를 정의하는 열거형을 정의한다.

 

 

public abstract class BTNode
{
    protected List<BTNode> children = new List<BTNode>();

    public void AddChild(BTNode node)
    {
        children.Add(node);
    }

    public virtual BTNodeState Evaluate()
    {
        return BTNodeState.Failure;
    }
}

트리 구조에서 사용할 노드를 추상 클래스로 정의한다. childeren에 저장되는 노드는 트리에서 자식 노드를 뜻하며 Evaluate 메서드는 BT에서 핵심적인 역할을 하는 메서드로 상속받은 클래스에서 각 역할에 맞는 기능을 수행하고 평가 결과를 반환도록 구현한다.

다음부터는 노드들을 상속받는 각 노드 타입들을 구현해보자.

 

public class BTAction : BTNode
{
    private System.Func<BTNodeState> action;

    public BTAction(System.Func<BTNodeState> action)
    {
        this.action = action;
    }

    public override BTNodeState Evaluate()
    {
        return action();
    }
}

BT의 리프 노드에 해당하는 액션 노드이다. 특정 기능을 수행 후 노드의 평가 결과를 반환하는 기능을 하며 액션 노드의 형태는 액션 노드를 다시 상속받아 기능을 별도로 구현할 수 있지만 여기서는 간단하게 델리게이트로 수행하도록 한다.

 

public class BTSequence : BTNode
{
    public override BTNodeState Evaluate()
    {
        bool isAnyChildRunning = false;

        foreach (BTNode node in children)
        {
            BTNodeState result = node.Evaluate();
            if (result == BTNodeState.Failure)
            {
                return BTNodeState.Failure;
            }
            else if (result == BTNodeState.Running)
            {
                isAnyChildRunning = true;
            }
        }

        return isAnyChildRunning ? BTNodeState.Running : BTNodeState.Success;
    }
}

시퀀스 노드는 앞서 설명했던 컴포지트 노드 중 하나로 자식 노드들을 순차적으로 실행하는 노드이다. 트리 그래프 상 왼쪽부터 오른쪽 노드(코드상으로는 먼저 입력된 순으로 처리)로 순차적으로 실행하며 모든 자식 노드가 Success를 반환할 때 까지 실행을 계속한다. 만약 자식 노드 중 Failure를 반환한다면 즉시 실행을 중단하고 Failure를 반환한다.

 

public class BTSelector : BTNode
{
    public override BTNodeState Evaluate()
    {
        foreach (BTNode node in children)
        {
            BTNodeState result = node.Evaluate();
            if (result == BTNodeState.Success)
            {
                return BTNodeState.Success;
            }
            else if (result == BTNodeState.Running)
            {
                return BTNodeState.Running;
            }
        }

        return BTNodeState.Failure;
    }
}

셀렉터 노드 또한 컴포지트 노드 중 하나이며 마찬가지로 자식 노드를 왼쪽부터 오른쪽으로 순차적으로 실행하지만 자식 노드가 Success를 반환하면 즉시 실행을 중단하고 Success를 반환한다. 만약 모든 자식 노드가 Failure를 반환했다면 실행을 중단하고 Failure를 반환한다.

 

public class BTCondition : BTNode
{
    private System.Func<bool> condition;

    public BTCondition(System.Func<bool> condition)
    {
        this.condition = condition;
    }

    public override BTNodeState Evaluate()
    {
        return condition() ? BTNodeState.Success : BTNodeState.Failure;
    }
}

컨디션 노드는 노드 타입 중 데코레이터 노드 중 하나로, 조건을 검사하여 해당 조건식이 참이면 Success, 거짓이면 Failure를 반환하는 코드이다.  주로 시퀀스 노드나 셀렉터 노드와 함께 사용되어 자식 노드들의 실행을 제어하는 역할을 한다.

 

마지막으로 위에서 구현한 BT 노드들을 활용하는 몬스터 클래스 예시를 보자.

public class MonsterAI : MonoBehaviour
{
    public Transform playerTransform;
    public float attackRange = 1.5f;
    public float moveSpeed = 2.0f;

    private BTSelector root;

    private void Start()
    {
        root = new BTSelector();
        BTSequence attackSequence = new BTSequence();
        BTSequence chaseSequence = new BTSequence();
        BTAction attackAction = new BTAction(Attack);
        BTAction chaseAction = new BTAction(Chase);
        BTCondition playerInRange = new BTCondition(IsPlayerInRange);

        root.AddChild(attackSequence);
        root.AddChild(chaseSequence);
        attackSequence.AddChild(playerInRange);
        attackSequence.AddChild(attackAction);
        chaseSequence.AddChild(chaseAction);

        root.Evaluate();
    }

    private void Update()
    {
        root.Evaluate();
    }

    private bool IsPlayerInRange()
    {
        float distanceToPlayer = Vector3.Distance(transform.position, playerTransform.position);
        return distanceToPlayer <= attackRange;
    }

    private BTNodeState Attack()
    {
        Debug.Log("Attacking player!");
        return BTNodeState.Success;
    }

    private BTNodeState Chase()
    {
        Debug.Log("Chasing player!");
        transform.position = Vector3.MoveTowards(transform.position, playerTransform.position, moveSpeed * Time.deltaTime);
        return BTNodeState.Running;
    }
}

위 코드에서 구현된 몬스터 AI는 플레이어를 추적하면서 플레이어가 공격할 수 있는 거리안에 있을 때 공격한다.

Start에서 각 노드들을 선언해 몬스터 AI를 위한 BT를 생성하고 Update문에서 매 프레임마다 루트를 평가하고 잇다.

 

위 코드의 BT를 그림으로 표현하면 다음과 같다.

- 루트는 셀렉터이기 때문에 왼쪽 자식 노드(attackSequence)를 먼저 평가한다.

- attackSequence는 시퀀스로 왼쪽 자식부터 오른쪽 자식 노드로 순차적으로 평가하기 때문에 플레이어가 사정거리 안에 있는지 체크하는 playerRange를 먼저 평가한다.

 

[분기]

1.  playerRange가 참이라면 Success를 반환하기 때문에 시퀀스는 다음 노드인 attackAction이 실행된다.  

2.  playerRange가 거짓이라면 Failure를 반환하기 때문에 시퀀스는 즉시 중단되어 Failure를 반환한다. root 노드는 셀렉터이기 때문에             attckSequence에서 Failure가 반환되었다면 다음 노드인 chaseSequence를 평가한다.

'Unity' 카테고리의 다른 글

[Unity] FSM - 유한 상태 기계  (2) 2023.03.13
[Unity] Normal Map  (0) 2023.01.30
[Unity] 텍스처 타입  (0) 2023.01.24
[Unity] 텍스처 압축 포맷  (0) 2023.01.16
[Unity] 텍스처와 텍스처 압축  (2) 2023.01.08

FSM(finite-state machine)은 상태(state)를 기반으로 동작을 제어하는 방식을 구현하기 위한 디자인 패턴이다.

FSM의 핵심은 단 하나의 상태만을 가진다는 점이다. 상태를 기준으로 어떤 동작을 수행할지 결정하기 때문에 현재 상태만 알 수 있으면 어떤 동작을 수행하려 하는지 명확히 파악할 수 있고 구현이 쉽다는 장점이 있다.

 

예를 들어 어떤 게임의 몬스터가 한 자리에서 플레이어를 기다리다 플레이어가 근처에 있다면 다가가 공격하는 몬스터 AI를 생각해보자.

이 몬스터의 행동은 다음 3가지 상태로 구분할 수 있다.

 

1. Idle

- 아무런 행동 없이 대기하고 있는 상태

- 플레이어가 인지 가능 범위 안에 있다면 Move상태로 전환

- 플레이어가 공격 가능한 거리에 있다면 Attack상태로 전환

 

2. Move

- 플레이어에게 이동하는 상태

- 플레이어가 인지 가능 범위를 벗어나면 Idle상태로 전환

- 플레이어가 공격 가능한 거리에 있다면 Attack상태로 전환

 

3. Attack

- 플레이어를 공격하는 상태

- 플레이어가 인지 가능 범위를 벗어나면 Idle상태로 전환

- 플레이어가 공격 가능 거리를 벗어나고 인지 가능 범위 안에 있다면 Move상태로 전환

 

앞서 말한 상태와 상태 전이 조건을 그래프로 정리하면 다음과 같다.

 

아래 코드는 Unity에서 가장 단순한 방법으로 FSM을 구현한 예시이다. 열거형으로 상태를 정의하고 Update에서 switch문을 통해 현재 상태에 따라 다른 동작을 수행하도록 하는 것만으로 FSM을 구현할 수 있다.

using UnityEngine;

public class Monster : MonoBehaviour
{
    private enum State
    {
        Idle,
        Move,
        Attack
    }
    private State _state;

    private void Start()
    {
        _state = State.Idle;
    }

    private void Update()
    {
        switch (_state)
        {
            case State.Idle:
                // Idle 행동 구현...
                break;
            case State.Move:
                // Move 행동 구현...
                break;
            case State.Attack:
                // Attack 행동 구현...
                break;
        }
    }
}

이러한 방식은 매우 간단히 구현 가능하다는 장점이 있지만 만약 Monster클래스를 상속받아 다양한 종류의 몬스터를 구현한다고 가정하면 모든 종류의 몬스터가 같은 AI 로직을 사용하지 않는 이상 사용하기 적합한 방식은 아닐 것이다.

 


 

그렇기 때문에 다음은 Monster클래스를 상속받은 Slime 클래스를 구현하면서 이를 해결하기 위한 조금 더 확장된 FSM 방식을 알아보자.

public abstract class BaseState
{
    protected Monster _monster;

    protected BaseState(Monster monster)
    {
        _monster = monster;
    }
    
    public abstract void OnStateEnter();
    public abstract void OnStateUpdate();
    public abstract void OnStateExit();
}

우선 BaseState라는 추상 클래스를 정의한다. 이 클래스는 각 상태를 구현하기 위한 필수적인 내용을 미리 정의하는 추상 클래스이다.

우리는 FSM이 Monster의 행동을 제어하기 위한 용도로만 사용된다는 가정 하에 초기화 시 Monster를 내부 변수로 저장하도록 한다.

- OnStateEnter : 상태에 처음 진입했을 때 한 번만 호출되는 메서드

- OnStateUpdate : 매 프레임마다 호출되어야 하는 메서드

- OnStateExit : 상태가 변경되면 호출되는 메서드

 

public class IdleState : BaseState
{
    public IdleState(Monster monster) : base(monster) { }
    
    public override void OnStateEnter()
    {
    }

    public override void OnStateUpdate()
    {
    }

    public override void OnStateExit()
    {
    }
}

public class MoveState : BaseState
{
    public MoveState(Monster monster) : base(monster) { }
    
    public override void OnStateEnter()
    {
    }

    public override void OnStateUpdate()
    {
    }

    public override void OnStateExit()
    {
    }
}

public class AttackState : BaseState
{
    public AttackState(Monster monster) : base(monster) { }
    
    public override void OnStateEnter()
    {
    }

    public override void OnStateUpdate()
    {
    }

    public override void OnStateExit()
    {
    }
}

각 몬스터 AI가 지닐 수 있는 각 상태들은 BaseState를 상속받은 각각의 클래스로 구현한다.

위 예시에서는 각 메서드의 내용이 구현되어있지는 않지만 상태에 따라 수행해야 할 행동을 이곳에서 구현하면 된다.

이때 주의할 점은 상태 변경에 대한 로직은 작성하면 안 된다.

BaseState를 상속받은 클래스는 다른 BaseState 자식 클래스들에 대해 알 수 없고 오직 어떤 행동을 수행해야 하는지에 대한 내용만을 구현한다. 상태 변경에 대한 책임은 Monster클래스에게 있으며 각 Monster클래스 구현에 따라 서로 가질 수 있는 상태가 다를 수 있기 때문이다.

 

public class FSM
{
    public FSM(BaseState initState)
    {
        _curState = initState;
        ChangeState(_curState);
    }

    private BaseState _curState;
    
    public void ChangeState(BaseState nextState)
    {
        if (nextState == _curState)
            return;
        
        if (_curState != null)
            _curState.OnStateExit();

        _curState = nextState;
        _curState.OnStateEnter();
    }

    public void UpdateState()
    {
        if (_curState != null)
            _curState.OnStateUpdate();
    }
}

FSM 클래스는 BaseState 기반으로 현재 상태 혹은 상태 변경 시 호출되어야 할 메서드를 관리해 주는 클래스이다.

Monster 클래스는 FSM 객체를 멤버 변수로 저장해 해당 객체를 통해 상태 제어를 해야 한다.

 

 

public class Slime : Monster
{
    private enum State
    {
        Idle,
        Move,
        Attack
    }

    private State _curState;
    private FSM _fsm;
    
    private void Start()
    {
        _curState = State.Idle;
        _fsm = new FSM(new IdleState(this));
    }

    private void Update()
    {
        switch (_curState)
        {
            case State.Idle:
                if (CanSeePlayer())
                {
                    if (CanAttackPlayer())
                        ChangeState(State.Attack);
                    else
                        ChangeState(State.Move);
                }
                break;
            case State.Move:
                if (CanSeePlayer())
                {
                    if (CanAttackPlayer())
                    {
                        ChangeState(State.Attack);
                    }
                }
                else
                {
                    ChangeState(State.Idle);
                }
                break;
            case State.Attack:
                if (CanSeePlayer())
                {
                    if (!CanAttackPlayer())
                    {
                        ChangeState(State.Move);
                    }
                }
                else
                {
                    ChangeState(State.Idle);
                }
                break;
        }
        
        _fsm.UpdateState();
    }

    private void ChangeState(State nextState)
    {
        _curState = nextState;
        switch (_curState)
        {
            case State.Idle:
                _fsm.ChangeState(new IdleState(this));
                break;
            case State.Move:
                _fsm.ChangeState(new MoveState(this));
                break;
            case State.Attack:
                _fsm.ChangeState(new AttackState(this));
                break;
        }
    }

    private bool CanSeePlayer()
    {
        // TODO:: 플레이어 탐지 구현
    } 
    
    private bool CanAttackPlayer()
    {
        // TODO:: 사정거리 체크 구현
    } 
}

마지막으로 Monster 클래스를 상속받은 Slime 클래스의 코드이다.

먼저 열거형 State에서 슬라임이 가질 수 있는 상태를 정의한다. 모든 Monster 클래스의 자식들은 가질 수 있는 상태가 모두 다르다. 예를 들어 공격을 할 수 없는 몬스터는 Attack상태가 없을 것이고 비행이 가능한 몬스터를 구현하고자 한다면 Fly상태가 추가될 수 있다.

 

이후 Start에서 FSM 객체 생성과 처음 상태를 정의한 후 Update의 Switch문 안에서 각 상태 전이를 위한 조건을 체크한다.

앞서 구현했던 간단한 FSM과의 가장 큰 차이는 실제 상태 별 행동을 구현할 필요가 없다는 것이다. 오직 상태 전이를 위한 조건만을 체크하기 때문에 코드 가독성을 높일 수 있고 슬라임 외 다른 몬스터를 구현하더라도 상태가 같다면 코드를 다시 작성할 필요가 없다.

 

단 각각의 상태들을 뚜렷하게 정의할 수 없거나 더욱 복잡한 AI를 구현하고자 한다면 상태의 종류가 많아지고 이는 곧 상태 전이 조건을 위한 수많은 if-else로 가득 차게 되고 가독성 저하와 디버깅이 어려워져 버그 가능성을 높이게 된다.

FSM은 상태의 수가 적고 상태별 행동이 명확하게 구별될 때 가장 효율이 좋은 디자인 패턴이기 때문에 이러한 특징을 미리 고민해 보고 사용해야 할 것이다.

'Unity' 카테고리의 다른 글

[Unity] Behavior Tree  (0) 2023.03.20
[Unity] Normal Map  (0) 2023.01.30
[Unity] 텍스처 타입  (0) 2023.01.24
[Unity] 텍스처 압축 포맷  (0) 2023.01.16
[Unity] 텍스처와 텍스처 압축  (2) 2023.01.08

Unity Texture Type 중 하나인 Normal Map(노멀맵)에 대해 알아보자

 

우선 노멀맵은 실제 지오메트리로 표현되는 것처럼 광원을 받는 범프, 홈 및 스크래치 등의 표면 디테일을 모델에 추가하는 데 사용할 수 있는 특별한 텍스처 종류라 한다.

 

쉽게 말해 실제로 모델링 된 것이 아닌 작은 디테일을 마치 모델링 된 것처럼 보이도록 할 수 있는 텍스처인 것이다.

 

 

왼쪽 사진 속 금속 표면은 폴리곤 수가 적은 평면이지만 노멀맵을 통해 나사, 홈, 스크레치는 광원을 받고 깊이감이 있는 것처럼 보인다.

 

노멀맵은 노멀 방향 정보. 즉 물체의 표면 방향 정보만을 표현하는 텍스처이기 때문에 물체 표면의 색상 정보와 무관하다.

 

즉 색상 정보는 일반적인 텍스처를 통해 표현하되 표면의 질감 처리만 따로 담당하는 것이며 기본 텍스처와 노멀맵 텍스처를 함께 사용한다.

 

 

유니티 노멀맵 공식 문서에 따르면 Normal Map과 Height Map은 모두 Bump Map 타입이라 한다. 

 

Bump Map은 Normal Map 등장 이전 하나의 채널인 흑백 이미지, 즉 그레이 스케일을 사용해 표면의 높낮이를 표현한다. 물체의 표면이 밝을수록 높고 어두울수록 낮아진다.

 

Bump Map은 매우 단순한 원리로 렌더링에 영향을 줄 수 있지만 정확한 모양을 만들어 낼 순 없었다. 반면 Normal Map은 RGB 세 가지 채널을 모두 사용하고 각 채널의 값을 x, y, z 축을 가진 법선 벡터 값으로 계산해 수직, 수평, 깊이의 정보를 모두 가질 수 있기 때문에 더욱 정교한 표현이 가능하다.

 

혼란스러웠던 점은 Bump Map이 그레이 스케일을 이용해 높낮이를 표현하는 것이라 했는데 이 부분은 Height Map의 설명과 동일하다.

 

Height Map 또한 그레이스케일을 이용하고 이름부터 높낮이를 표현하기 위한 텍스처 타입으로 보여 같은 개념인가 싶지만 일반적으로 Height Map은 앞서 알아본 Bump Map, Normap Map과 같이 지오메트리의 변형을 주지 않으면서 눈속임을 주는 Bump Mapping 방식이 아닌 실제로 지오메트리를 변형시키기 위해 사용하는 Displacement Map의 다른 이름으로 구분하는 것 같다.

 

그래도  Unity에서는 결국 그레이스케일만을 통해 높이를 표현하는 점은 동일하기 때문에 Height Map 타입을 위해 제작된 텍스처라도 Texture Type을 Normal Map으로 변경 후 Create from Grayscale 옵션을 켜주면 Bump Map과 같이 사용할 수 있다고 한다.

'Unity' 카테고리의 다른 글

[Unity] Behavior Tree  (0) 2023.03.20
[Unity] FSM - 유한 상태 기계  (2) 2023.03.13
[Unity] 텍스처 타입  (0) 2023.01.24
[Unity] 텍스처 압축 포맷  (0) 2023.01.16
[Unity] 텍스처와 텍스처 압축  (2) 2023.01.08

유니티 프로젝트에 텍스처를 클릭하면 인스펙터창에서 Texture Import Settings 창을 확인할 수 있다.

텍스처 임포트 세팅에서 설정 가능한 각 텍스처 타입에 대해 간단히 알아보자 (2020.3.30f1 버전 기준)

 

Texture Type

- Default

모든 텍스처에 사용되는 가장 일반적인 설정. 대부분의 텍스처 임포트 프로퍼티에 엑세스할 수 있다.

 

- Nomal map

컬러 채널을 실시간 노멀 매핑에 적합한 포맷으로 변경할 때 사용.

* 노멀 매핑(=범프 매핑)이란?

   실제 지오메트리처럼 광원을 받는 표면 디테일을 모델에 추가하기 위한 방식.

   이 과정에서 노멀맵과 헤이트맵이라는 특수한 텍스처를 사용한다.

 

- Editor GUI and Legacy GUI

유니티 이전에 GUI 타입으로 사용된 방식. NGUI에서 주로 사용하는 방식

UGUI에서는 Sprite 타입을 사용한다.

 

- Sprite (2D and UI)

텍스처가 2D 게임에서 스프라이트로 사용하거나 UI인 경우 사용한다.

 

- Cursor

게임 내 커스텀 마우스 커서로 사용하기 위한 텍스처 타입

 

- Cookie

씬의 광원 쿠키에 사용되는 기본 파라미터 텍스처

* 쿠키란? 

   실제 배치된 오브젝트가 아닌 사물의 그림자 연출을 위해 광원에 특정 형태의 마스크로 사용되는 텍스처

 

- Lightmap

텍스처를 라이트맵으로 사용

* 라이트맵이란?

  움직이지 않는 정적인 물체에 대해 미리 조명효과를 설정하여 텍스처로 만든 것.

 

- Directional Lightmap

텍스처를 일반 라이트맵보다 조명 환경에 대한 더 많은 정보를 저장하는 방향성 라이트맵으로 사용

 

- Single Channel

텍스처에 단 하나의 채널만 필요한 경우 사용

선택 가능한 채널은 Alpha, Red 두가지 이다.

'Unity' 카테고리의 다른 글

[Unity] Behavior Tree  (0) 2023.03.20
[Unity] FSM - 유한 상태 기계  (2) 2023.03.13
[Unity] Normal Map  (0) 2023.01.30
[Unity] 텍스처 압축 포맷  (0) 2023.01.16
[Unity] 텍스처와 텍스처 압축  (2) 2023.01.08

프로젝트 창에서 텍스처를 클릭하면 인스펙터에서 Texture Import Settings를 확인할 수 있다.

이때 인스펙터 하단을 보면 각 플랫폼 별 텍스처 압축 관련 설정이 가능하다.

기본적으로는 현재 빌드 플랫폼에 따라 적합한 텍스처 포맷으로 자동 변환하지만 각 플랫폼별 오버라이드 옵션을 체크하여 직접 포맷을 지정할 수 있다. 물론 RGBA 32bit, RGB 24bit와 같이 별다른 압축 포맷으로 변환하지 않는 무압축 옵션을 지정할 수도 있다. 지정한 포맷을 지원하지 않는 기기에서 앱을 설치하는 경우 무압축 포맷으로 읽어들인다. 

 

플랫폼별 디폴트 포맷

https://docs.unity3d.com/kr/2018.4/Manual/class-TextureImporterOverride.html

 

그럼 모바일 게임에서 사용되는 대표적인 텍스처 압축 포맷(Texture Compression Format, TCF)의 특징들을 간단히 알아보자

 

PVRTC

유니티의 iOS 기본 압축 형식이자 모든 iOS 기기에서 사용할 수 있는 효율성 높은 압축 포맷이다.

PowerVR 칩셋을 사용하는 텍스처 압축 포맷으로 4bpp, 2bpp 두가지의 정밀도 타입이 나뉜다.

PVRTC1과 상위 호환인 PVRTC2 버전이 있으나 아이폰/아이패드가 PVRTC2를 지원하지 않기 때문에 유니티는 PVRTC1만 지원한다.

텍스처의 해상도가 2의 지수승 정사각형이어야 한다는 제약이 있다. 

 

ETC

유니티에서 안드로이드를 타겟으로 하는 경우 사용되는 텍스처 압축 포맷이다.

ETC의 경우 ETC1과 ETC2로 나뉘는데 ETC2는 ETC1을 확장 및 개선한 포맷이다.

즉 ETC1보다 ETC2를 사용하는 것이 성능상 더 좋지만 Open GL ES 3.0 이상에서만 지원 가능하다.

https://ozlael.tistory.com/42

 

ASTC (Adaptive Scalable Texture Compression)

PVRTC나 ETC와 마찬가지로 손실 블록 기반 텍스처 압축 알고리즘이다.

ASTC는 ETC, PVRTC에 비해 많은 장점이 있기 때문에 ASTC 포맷을 사용할 수 있는 상황이라면 다른 포맷보다 ASTC를 사용할 것을 권한다고 한다. 

우선 ASTC는 안드로이드와 iOS 모두 지원하기 때문에 유지보수가 편하다.

또한 ASTC는 가변 블록 크기를 사용한다. 유니티에서 ASTC 4x4 ~ 12x12까지의 블록 크기를 선택할 수 있는데 이러한 특징 덕분에 텍스처의 중요도, 복잡도에 따라 유연하게 조절할 수 있으면서 ETC에 비해 더 복잡한 인코딩 과정을 거치기 때문에 압축 시간은 조금 더 걸리지만 일반적으로 더 좋은 품질을 얻을 수 있다.

단 ASTC 지원 기기 범위에 대해 신경 쓸 필요가 있다.

ASTC는 OpenGL ES 3.2 및 OpenGL ES 3.1+AEP GPU와 일부 OpenGL ES 3.0 GPU에서 지원한다.

물론 2020년 Google Play 기기의 ASTC 지원 비율을 보면 77%로 사실상 주요 타게팅 기기 대부분이 ASTC를 지원한다고 볼 수 있지만 현재 프로젝트가 인도네시아, 동남아 같은 신흥국 유저나 최대한 많은 기기를 타게팅해야 한다면 ASTC 사용을 더 신중히 고민해야 한다.

 

https://developer.android.chttps://developer.arm.com/documentation/102162/0200/Unity-and-ASTC?lang=en om/guide/app-bundle/asset-delivery/texture-compression?hl=ko 

 

'Unity' 카테고리의 다른 글

[Unity] Behavior Tree  (0) 2023.03.20
[Unity] FSM - 유한 상태 기계  (2) 2023.03.13
[Unity] Normal Map  (0) 2023.01.30
[Unity] 텍스처 타입  (0) 2023.01.24
[Unity] 텍스처와 텍스처 압축  (2) 2023.01.08

텍스처

텍스처는 비트맵 이미지이다. 즉 Unity 프로젝트에 포함된 모든 이미지 파일은 텍스처로 인식된다.

그럼 Unity의 텍스처에 대해 알아보기 전 비트맵에 대한 간단한 정리를 먼저 해보자

 

* 비트맵(Bitmap)

레스터 그래픽으로도 불리며 서로 다른 픽셀들에 저장된 비트 정보 집합을 사용하는 이미지 표현 방식이다.

가로 픽셀의 수 * 세로 픽셀의 수가 비트맵 이미지의 크기가 되며 색 표현을 위한 픽셀당 비트 수인 bpp (Bit Per Pixel)를 지정할 수 있다.  

에시로 4bpp의 경우 2^4인 16가지 색을, 8pp를 지정한다면 2^8인 256가지 색을 지정할 수 있다

 

16bpp의 경우 하이 컬러, 24bpp 경우 16,777,216개의 색상을 사용할 수 있으며 사람이 볼 수 있는 색이라는 뜻에서 트루 컬러라고 부른다.

빛의 3원색인 빨간색, 녹색, 파란색의 채널로 이루어진 것을 RGB채널이라 부른다. 24bpp인 트루컬러의 경우 각 채널에 8비트씩 할당된다.

이때 투명도 값을 의미하는 알파 채널이 더해져 32bpp를 사용하는 것을 RGBA채널이라 부른다.

 

* 비트맵 그래픽 파일 형식

이러한 배트맵을 파일로 저장하는 과정에서 파일의 크기를 줄이기 위해 비트맵을 압축하여 저장하는데 압축 방식과 지원하는 기능에 따라 다양한 표준 형식이 있다. 그중 대표적인 표준 형식 몇 가지의 간단한 특징만 정리했다.

 

BMP

일반적으로 픽셀당 24비트를 저장하며 압축되지 않는 파일 형식이다.

 

GIF

GIF는 무손실 압축 포맷이기 때문에 압축과정에서 정보의 손실이 발생하지 않는다. GIF의 한 색을 투명으로 저장할 수 있으며 픽셀당 최대 8비트까지 저장되므로 256색으로 제한된다.

 

PNG

GIF 파일과 마찬가지로 PNG 파일은 정보 손실 없이 압축된다. 픽셀당 256색으로 제한되던 GIF와는 달리 트루 컬러인 1,600만 개의 색상을 지원한다.

또한 GIF처럼 한 가지 색을 투명값으로 사용하는 것이 아닌 별도의 알파 채널을 사용하여 완벽한 불투명도를 지정할 수 있다.

 

JPEG

JPEG는 손실 압축 방식으로 파일이 압축될 때 이미지의 일부 데이터가 영구적으로 삭제된다. PNG와 마찬가지로 1,600만 개 이상의 색을 표시할 수 있다. 단 크기는 PNG에 비해 파일 크기가 작다는 장점이 있다. 스캔한 사진 혹은 자연 장면에 잘 작동하는 압축 체계. 선, 단색 블록, 날카로운 경계 표현에 경우 다소 약하다. PNG와의 가장 큰 차이는 JPEG는 투명한 배경을 지원하지 않는 점이다.

 

* 비트맵(레스터 그래픽)과 반대되는 벡터(Vector) 그래픽도 존재한다. 이는 점과 점을 연결해 수학적 원리로 그림을 그리는 이미지 표현 방식이다.

 

즉 Unity는 흔히 사용하는 이미지 파일(GIF, PNG, JPG)들을 통해 게임에서 사용하는 텍스처로 이용할 수 있다는 뜻이다.

그럼 게임에서 텍스처를 어떻게 활용하는지 알아보자.

3D 모델의 경우 모델 표면에 이미지를 매핑하기 위해 텍스처를 사용한다.

3D 게임의 오브젝트는 MeshRenderer 컴포넌트를 통해 렌더링 되는데 이때 필요한 리소스로 모델의 형태 정보인 Mesh와 표면의 색을 결정하는 Material 두 가지 에셋이 필요하다.

Material은 텍스처와 셰이더가 합쳐진 에셋으로 즉 3D 모델의 표면 색을 지정하려면 Material 에셋에 원하는 텍스처를 연결하면 된다.

 

2D 게임 오브젝트는 SpriteRenderer, UI 이미지의 경우 Image컴포넌트를 통해 렌더링 되는데 이때 사용할 리소스 이미지로 Sprite가 필요하다.

프로젝트 창의 텍스처파일을 클릭하면 인스펙터 창에 표시되는 Import Settings에서 Texture Type을 Sprite(2D and UI)로 변경하면 해당 텍스처를 Sprite로 인식하고 2D 게임 오브젝트나 UI 이미지에 사용할 수 있다.

 

텍스쳐 압축

JPG, PNG과 같이 비트맵 데이터를 이미지 압축 방식을 통해 데이터 사이즈를 줄여 디스크 용량을 절약할 수 있었지만 런타임 시 게임 그래픽에서 그대로 사용할 수 없다. JPG나 PNG는 GPU가 읽어 들일 수 있도록 비디오 메모리에 올리면 압축한 상태가 아닌 압축이 풀린 상태로 올라간다.

2048 * 2048 크기의 알파 채널을 포함한 PNG 파일의 경우 압축된 원본 크기가 2~3MB였을지라도 그래픽 메모리에 올라갈 때는 16MB로 올라간다는 것이다.

 

따라서 Unity 에디터에서 JPEG 또는 PNG 같은 비트맵 이미지 포맷의 텍스쳐 소스 파일을 가져올 수 있지만 GPU는 런타임 시 이러한 포맷을 직접 사용하지 않는다. 대신 Unity 에디터에서 빌드 타겟에 따라 적합한 다양한 전문 포맷으로 변환하여 사용한다. 즉 GPU에서 사용하기 위한 픽셀 포맷이 별도로 존재한다는 것인데 이러한 포맷으로 변환하는 것을 텍스쳐 압축이라 한다. Unity 빌드 결과물 또한 프로젝트 폴더에 있는 이미지 파일이 아닌 텍스처 압축을 통해 전문 포맷으로 변환된 파일이 빌드에 포함된다.

 

게임에서 사용하는 텍스처 압축 포맷들은 다음 사항을 만족해야 한다.

- 디코딩 속도가 빨라야 한다

압축된 텍스처는 메모리에 로드될 때 압축이 풀려서 적재되는 것이 아니라 압축된 데이터 그대로 메모리에 적재되어야 한다. 그 후 렌더링 퍼포먼스에 발목을 잡히지 않기 위해 압축 해제가 고속으로 이루어져야 한다. 압축의 디코딩은 하드웨어에 구현되어 있으므로 따라서 텍스처 압축 포맷은 어떤 하드웨어를 사용하느냐에 따라 지원 여부가 의존적이다. 

 

- 랜덤 액세스가 가능해야 한다

랜덤 액세스(Random Access)는 직접 액세스로도 불리며 논리적, 물리적인 순서가 아닌 한번에 단 하나의 블록만을 접근하는 방식이며 이와 반대로 시퀀셜 액세스는 논리적 혹은 물리적으로 연결된 순서에 따라 차례대로 블록을 읽는 방식이다.

즉 랜덤 엑세스가 가능한 경우 시퀀셜 액세스보다 더욱 빠르게 메모리를 읽을 수 있다.

 

도형 표면에 텍스처가 입혀지려면 보통 uv 혹은 st라 부르는 텍셀 좌표가 필요한데 이는 현재 그려지는 픽셀이 어느 지점의 컬러를 가져와야 하느냐의 정보가 담긴 죄표이다. GPU는 특정 위치의 텍셀을 바로 읽어와야 하기 때문에 랜덤 액세스가 필수적이다.

 

JPG, PNG와 같은 이미지 압축에 사용되는 가변 비율 압축은 랜덤 액세스에 적합하지 않다. 따라서 텍스처 압축은 반드시 고정 비율 압축이어야 한다.

가변 비율 압축은 데이터가 중복된 단위를 기준으로 압축한다. 동일한 해상도의 이미지라도 이미지 복잡도에 따라 압축률이 달라지는 방식이다.

고정 비율 압축의 경우 4:1, 8:1, 16:1과 같이 고정된 비율로 압축하며 모든 결과물의 압축률이 같아진다. 압축률이 같다면 어느 위치의 데이터를 읽어야 하는지 충분히 예측 가능해지기 때문에 랜덤 액세스가 가능하다.

 

텍스처 압축은 손실 압축이다. 압축된 텍스처를 디코딩 하는 과정이 너무 어렵거나 복잡하다면 그만큼의 연산이 많이 필요하고 성능 효율이 떨어진다. 그렇기 때문에 압축 효율이 높으면서 디코딩이 너무 복잡하지 않은 손실 압축 알고리즘을 채용하고 있다.

 

[참고]

https://docs.unity3d.com/kr/current/Manual/ImportingTextures.html

https://www.youtube.com/watch?v=BeEjoTa9sSo&t=689s 

https://learn.microsoft.com/ko-kr/dotnet/desktop/winforms/advanced/types-of-bitmaps?view=netframeworkdesktop-4.8

 

'Unity' 카테고리의 다른 글

[Unity] Behavior Tree  (0) 2023.03.20
[Unity] FSM - 유한 상태 기계  (2) 2023.03.13
[Unity] Normal Map  (0) 2023.01.30
[Unity] 텍스처 타입  (0) 2023.01.24
[Unity] 텍스처 압축 포맷  (0) 2023.01.16

+ Recent posts