아래와 같은 유한 상태 머신 구현을 위해 상태를 주기적으로 업데이트하는데 매 프레임 하는 건 부담을 주므로 0.2~0.3초간격으로 해도 된다면 코루틴을 적극 활용하겠다.

적 캐릭터의 상태체크

0.3초 마다 상태를 체크한다. Player와 Monster의 거리를 측정해 추적 사정거리와 공격 사정거리를 판단한 다음 상태를 변경한다. MonsterCtrl 스크립트를 다음과 같이 수정한다.

Enum을 사용하면 인스펙터뷰에 리스트박스로 표시되면 가독성을 높혀준다.

Start()함수를 비롯 2개의 코루틴 CheckMonsterState() MonsterActionState()가 있다.

Start()는 참조조기화및 코루틴을 기동시킨다.

코루틴들은 while(!IsDie) 순환문으로 살아 있는 동안만 감시를 하다가 몬스터가 죽으면 isDie=true로 순환문을 빠져나와 종료한다.

 CheckMonsterState() 에서 float distance = Vector3.Distance(playerTr.position, monsterTr.position);거리를 측정해 state를 IDLE, ATTACK, TRACE로 바꾸준다.

MonsterActionState()에서는 state가 Trace라면 agent.SetDestination(playerTr.position);로 목표물을 지정해주고,  agent.isStopped = false로  추적을 시작한다.; 다른 state에 대해서도 적절한 처리를 한다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;  //for Navigation

public class MonsterCtrl : MonoBehaviour
{
    //몬스트 상태 
    public enum State {  //인스펙터뷰에 콤보박스로 표시됨
        IDLE, PATROL, TRACE, ATTACK, DIE
    }
    public State state = State.IDLE;
    public float traceDist = 10.0f;
    public float attackDist = 2.0f;
    public bool isDie = false;

    private Transform monsterTr;
    private Transform playerTr;
    private NavMeshAgent agent;

    public Vector3 pPos, mPos;
    void Start()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        //agent.destination = playerTr.position;
        StartCoroutine(CheckMonsterState());
        StartCoroutine(MonsterAction());
    }
    IEnumerator CheckMonsterState() {
        while(!isDie) {
            pPos = playerTr.position;
            mPos = monsterTr.position;
            yield return new WaitForSeconds(0.3f);
            //Player와 Monster간의 거리측정 스칼라값
            float distance = Vector3.Distance(playerTr.position, monsterTr.position);
            if(distance <= attackDist) {
                state = State.ATTACK; //Attack로 상태변경
            } else if(distance <= traceDist) {
                state = State.TRACE;  //Trace로 상태변경
            } else {
                state = State.IDLE;  //Idle로 상태변경
            }
        }
    }
    IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true;
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);
                    agent.isStopped = false;
                    break;
                case State.ATTACK:
                    break;
                case State.DIE:
                    break;
            }
            yield return new WaitForSeconds(0.3f);
        }
    }
    private void OnDrawGizmos() {  //상태에 따라 기즈모 색깔 변경
        if(state == State.TRACE) {
            Gizmos.color = Color.blue;
            Gizmos.DrawWireSphere(transform.position, traceDist);
        }
        if(state == State.ATTACK) {
            Gizmos.color = Color.red;
            Gizmos.DrawWireSphere(transform.position, attackDist);
        }
    }
}

실행하면서 Monster의 인스펙터의 MonsterCtrl 스크립트뷰를 보면 거리가 좁혀지면 IDEL이 TRACE로 바뀌면 Monster가 추적을 시작하고 더 가가워지면 ATTACK로 바뀐다. 막 도망가서 거리를 벌리면 다시 IDLE상태로 된다.

마지막으로  OnDrawGizmos()는 씬뷰에서만 보인다. 디버그용이다.

트레이스 범위에서  범위에 해당하는 파란색 구를 그려주고 공격범위에서는 범위 크기의 빨간색 구를 그려준다.

애니메이션 동기화

몬스터는 유령처럼 움직임없이 따라오는데 애니메이션을 수행시켜 움직여보자. 애니메이터뷰에 IsTrace 변수를 설정해 놓았다. MonsterCtrl 스크립터에서 Monster의 Animator 컴포넌트에 접근하기 위한 변수를 설정및 할당하고 MonsterAction 함수에 Animator에서 생성한 변숫값을 변경한 변숫값을 변경하는 로직을 추가한다.

switch(state)문안에  case State.IDLE과 State.TRACE 때 IsTrace의 파라메터를false, true로 바꿔주는 anim.SetBool()이 추가되었다. 변경된  IsTrace의 파라메터는 애니메이터의 transition을 일어나게 한다.

IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool("IsTrace", false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool("IsTrace", true);
                    break;

MonsterCtrl 전체코드

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.AI;  //for Navigation


public class MonsterCtrl : MonoBehaviour
{
    //몬스트 상태 
    public enum State {  //인스펙터뷰에 콤보박스로 표시됨
        IDLE, PATROL, TRACE, ATTACK, DIE ,DANCE
    }

    public State state = State.IDLE;  //몬스트 현재상태
    public float traceDist = 10.0f;  //추적 사정거리
    public float attackDist = 2.0f; //공격 사정거리
    public bool isDie = false;  //몬스터사망여부

    private Transform monsterTr;  //컴포넌트의 캐시를 처리할 변수
    private Transform playerTr;  
    private NavMeshAgent agent;
    private Animator anim;

    void Start()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        anim = GetComponent<Animator>();
        //agent.destination = playerTr.position;
        StartCoroutine(CheckMonsterState());
        StartCoroutine(MonsterAction());
        
    }

    //일정한 간격으로 몬스터의 행동 상태를 체크
    IEnumerator CheckMonsterState() {
        while(!isDie) {
            yield return new WaitForSeconds(0.3f);
            //Player와 Monster간의 거리측정 스칼라값
            float distance = Vector3.Distance(playerTr.position, monsterTr.position);
            if(distance <= attackDist) {
                state = State.ATTACK; //Attack로 상태변경
            } else if(distance <= traceDist) {
                state = State.TRACE;  //Trace로 상태변경
            } else {
                state = State.IDLE;  //Idle로 상태변경
            }
        }
    }
    //몬스터의 상태에 따라 몬스터의 동작을 수행
    IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool("IsTrace", false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool("IsTrace", true);
                    break;
                case State.ATTACK: //공격상태
                    break;
                case State.DIE: //사망상태
                    break;

            }
            yield return new WaitForSeconds(0.3f);
        }
    }
    private void OnDrawGizmos() {  //상태에 따라 기즈모 색깔 변경
        if(state == State.TRACE) {

            Gizmos.color = Color.blue;
            Gizmos.DrawWireSphere(transform.position, traceDist);
        }
        if(state == State.ATTACK) {
            Gizmos.color = Color.red;
            Gizmos.DrawWireSphere(transform.position, attackDist);
        }
    }
}

 

몬스터 공격 루틴

몬스터에 공격 기능을 부여하자. 먼저 프로젝트 뷰의 MonsterAnim을 더블클릭해 애니메이터 뷰를 열고  attack 애니메이션 클립을 드래그해 배치한다. attack와 walk 스테이트간의 Transition 2개를 배치한다. 

walk->attack transition은 HasExitTime을 언체크하고 Conditions+를 누루고 IsAttack를 선택하고 true로

attack->walk transition은 HasExitTime을 언체크하고 Conditions+를 누루고 IsAttack를 선택하고 false로

MonsterCtrl 스크립터의 코루틴  MonsterAction() 내부의 Trace와 Attack case문에 IsAttack 파라메터 부분을 넣어준다

    IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool("IsTrace", false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool("IsTrace", true);
                    anim.SetBool("IsAttack", false);
                    break;
                case State.ATTACK: //공격상태
                    anim.SetBool("IsAttack", true);
                    break;

SetBool()은 파라메테명의 오자가 있으면 오류도 발생하고 호출될때 마다 해시테이블을 검색하므로 파라메터 해시값을 미리 추출해 전달하는 방식이 바람직하다. 우선 다음같이 스트링을 해시값으로 미리 바꿔 놓는다 "Hit"는 몬스터가 피격받을때 상태인데 미리 넣어두겠다

    public readonly int hashTrace = Animator.StringToHash("IsTrace");
    public readonly int hashAttack = Animator.StringToHash("IsAttack");
    private readonly int hashHit = Animator.StringToHash("Hit");

코루팅 switch()문안의 스트링을 해시값으로 변경한다.

IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool(hashTrace, false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool(hashTrace, true);
                    anim.SetBool(hashAttack, false); //공격파라메터 변경
                    break;
                case State.ATTACK: //공격상태
                    anim.SetBool(hashAttack, true);  //공격파라메터 변경
                    break;

실행해 보면 이제 몬스터가 걷고 공격한다. 이제부터는 몬스터가 피격받을때 취할 리액션을 붙여 넣겠다.

게임뷰탭옆 Ani탭을 누른후 Hit Trigger 파라메터를 하나 만든후 몬스터 모델에서 gothit을 끌어다 놓고 다음과 transition을 만든다.

Any->gothit transition은 HasExitTime 언체크, condition +를 누르고 Hit로 설정한다

gothit에서 idle 로 단방향 transition을 만든다 HasExitTime는 체크, ExitTime 0.9 isTrace false로 한다.

gothit에서 walk 로 단방향 transition을 만든다 HasExitTime는 체크, ExitTime 0.9 condition + IsTrace true, IsAttack false로 한다.

gothit에서 attack로 단방향 transition을 만든다 HasExitTime는 체크, ExitTime 0.9 condition + IsAttack true로 한다.

몬스터와 총알이 충돌이 나면 총알을 없애주고 hasHit을 Trigger시켜준다(setTrigger) 그러면 Any->gotHit transition이 일어난다.

private readonly int hashHit = Animator.StringToHash("Hit");

private void OnCollisionEnter(Collision coll) {
    if (coll.collider.CompareTag("BULLET")) {
        Destroy(coll.gameObject);             //충돌한 총알을 삭제
        anim.SetTrigger(hashHit);  //피격 리액셔  애니메이션 실행
    }
}

현재까지의 최종코드

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
using UnityEngine.AI;  //for Navigation


public class MonsterCtrl : MonoBehaviour
{
    //몬스트 상태 
    public enum State {  //인스펙터뷰에 콤보박스로 표시됨
        IDLE, PATROL, TRACE, ATTACK, DIE ,DANCE
    }

    public State state = State.IDLE;  //몬스트 현재상태
    public float traceDist = 10.0f;  //추적 사정거리
    public float attackDist = 2.0f; //공격 사정거리
    public bool isDie = false;  //몬스터사망여부

    private Transform monsterTr;  //컴포넌트의 캐시를 처리할 변수
    private Transform playerTr;  
    private NavMeshAgent agent;
    private Animator anim;

    public readonly int hashTrace = Animator.StringToHash("IsTrace");
    public readonly int hashAttack = Animator.StringToHash("IsAttack");
    private readonly int hashHit = Animator.StringToHash("Hit");

    void Start()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        anim = GetComponent<Animator>();
        //agent.destination = playerTr.position;
        StartCoroutine(CheckMonsterState());
        StartCoroutine(MonsterAction());
        
    }

    //일정한 간격으로 몬스터의 행동 상태를 체크
    IEnumerator CheckMonsterState() {
        while(!isDie) {
            yield return new WaitForSeconds(0.3f);
            //Player와 Monster간의 거리측정 스칼라값
            float distance = Vector3.Distance(playerTr.position, monsterTr.position);
            if(distance <= attackDist) {
                state = State.ATTACK; //Attack로 상태변경
            } else if(distance <= traceDist) {
                state = State.TRACE;  //Trace로 상태변경
            } else {
                state = State.IDLE;  //Idle로 상태변경
            }
        }
    }
    //몬스터의 상태에 따라 몬스터의 동작을 수행
    IEnumerator MonsterAction() {
        while (!isDie) {
            switch(state) {
                case State.IDLE:
                    agent.isStopped= true; //추적을 중지
                    anim.SetBool(hashTrace, false);
                    break;
                case State.TRACE:
                    agent.SetDestination(playerTr.position);  //목표설정
                    agent.isStopped = false; //추적을 재개
                    anim.SetBool(hashTrace, true);
                    anim.SetBool(hashAttack, false); //공격파라메터 변경
                    break;
                case State.ATTACK: //공격상태
                    anim.SetBool(hashAttack, true);  //공격파라메터 변경
                    break;
                case State.DIE: //사망상태
                    break;

            }
            yield return new WaitForSeconds(0.3f);
        }
    }
    private void OnCollisionEnter(Collision coll) {
        if (coll.collider.CompareTag("BULLET")) {
            Destroy(coll.gameObject);             //충돌한 총알을 삭제
            anim.SetTrigger(hashHit);  //피격 리액셔  애니메이션 실행
        }
    }
    private void OnDrawGizmos() {  //상태에 따라 기즈모 색깔 변경
        if(state == State.TRACE) {

            Gizmos.color = Color.blue;
            Gizmos.DrawWireSphere(transform.position, traceDist);
        }
        if(state == State.ATTACK) {
            Gizmos.color = Color.red;
            Gizmos.DrawWireSphere(transform.position, attackDist);
        }
    }
}

적 캐릭터가 장애물을 피해서 가는 걸 구현한다면 PathFinding같은 알고리즘이 필요하다. 유니티가 3D모델을 분석해 추적할 수 있는  내비게이션 기능을 제공하기 이전 많이 사용되었다. 유니티의 내비게이션 기능을 이용해 주인공을 추적하는 로직을 구현해 보자. 추적을 시작하면 walk애니메이션으로 변경하고 플레이어에 근접했을때 attack 애니메이션으로 변경되도록 메카님을 구성해본다.

네비게이션은 스테이즈를 구성하고 있는 3D메시(지형:Geometry)를 분석해 네비메시(NavMesh) 데이터를 미리 생성하는 방식이다. 추적할 수 있는 영역과 지나갈수 없는 영역을 미리 메시로 만들어 동작을 연출할 수 있다.

 

네비게이션 설정 - Navigation Static Flag

네비게이션을 만들(Bake)려면 그 대상이 무엇인지 지정해야 한다. 우선 바닥으로 사용되는 Floor의 static 옵션을 설정한다.  Floor 인스펙터의 이름 오른쪽의  Static옆 삼각형을 눌러 Navigation Static만 체크한다.

프리팹폴더의 Barrel도 Wall도 Static중 Navigation Static만 체크하고 다이얼로그가 나오면 children까지 변경해준다.

Monster를 선택하고. window>AI>Navigation을 선택하면 Navigation뷰가 나오고 Bake를 누르고 마치면 파란색 영역이 만들어진다. 드럼통 주위는 파란색 메시로 채워지지 않고 구멍이 나있다. 이 영영읍 지나갈 수 없는 영억으로 인식해 추적한 대상이 장애물로 판단한다. Bake는 씬뷰에 플레이전 있는 것들만 인식한다. 동적으로 생성되는 것은 예측할수 없는 것 같다. 따라서 스테이지에 적용된  Barrel 스크립트를 제거한고 프리팹 Barrel을 여기저기 배치하고 Bake를 한번 더 누른다.

플레이어와 몬스터를 떨어트려 놓과 사이에 배치해주기 바란다. 몬스터가 피해가는지

NavMeshAgent 컴포넌트

NavMeshAgent 컴포넌트는 목표를 향해 움직일 때 서로를 피해가는 캐릭터 생성에 유용합니다. 에이전트는 내비메시를 이용하여 게임 월드에 대해 추론하고 서로 또는 기타 움직이는 장애물을 피할 방법을 이해하고 있습니다. 내비메시 에이전트의 스크립팅 API를 이용하여 경로를 찾거나 공간을 추론할 수 있습니다.

 

하이라키에서 Monster를 선택하고 메뉴의 컴포넌트>Navigation>Nav Mesh Agent를 선택한다.

NaveMeshAgent의 각 속성은 다음과 같다.

프로퍼티 기능
에이전트 크기
Radius 에이전트의 반경은 장애물과 다른 에이전트 간의 충돌 계산하기 위해 사용됩니다.
Height 에이전트가 장애물 밑으로 지나갈 있는 높이 간격입니다.
Base offset 트랜스폼 피봇 포인트와 관련한 충돌 실린더의 오프셋입니다.
스티어링
Speed 최대 이동 속도(초당 월드 단위로)
Angular Speed 최대 회전 속도(초당 각도)
Acceleration 최대 가속(제곱 초당 월드 단위로)
Stopping distance 에이전트는 목표 위치에 가까워졌을 정지합니다.
Auto Braking 활성화 에이전트는 목적지에 다다를 속도를 줄입니다. 에이전트가 멀티플 포인트 사이에서 부드럽게 움직여야 하는 순찰과 같은 동작을 때에는 반드시 비활성화 시켜야 합니다.
장애물 회피
Quality 장애물 회피 품질입니다. 에이전트의 수가 많다면 장애물 회피 품질을 줄임으로써 CPU 시간을 절약할 있습니다. 회피를 없음으로 설정할 경우 충돌만 해결할 있을 다른 에이전트와 장애물에 대한 적극적인 회피는 하지 않습니다.
Priority 낮은 우선 순위의 에이전트는 에이전트의 회피 대상에서 제외됩니다. 값은 0에서 99사이에서 설정되어야 하며 낮은 숫자가 높은 우선 순위임을 의미합니다.
경로 찾기
Auto Traverse OffMesh Link 자동적으로 오프 메시 링크를 횡단하려면 트루로 설정해야 합니다. 애니메이션을 사용하거나 오프메시 링크를 횡단하는 특정한 방법을 사용하고 싶다면 반드시 이를 꺼놔야 합니다.
Auto Repath 활성화 에이전트가 경로 일부분의 끝에 도달하면 경로를 재탐색 합니다. 목적지까지 경로가 없다면 목적지에서 제일 가깝게 도달할 있는 위치까지 부분적인 경로가 생성됩니다.
Area Mask 영역 마스크는 에이전트가 경로 탐색에 어떠한 영역 타입 고려할 것인지를 설명합니다. 내비메시 베이킹를 위해 메시를 준비할 각각의 메시 영역 타입을 설정할 있습니다. 예를 들어 계단을 특별한 영역 타입으로 표시하고 몇몇 캐릭터 타입의 계단 이용을 금지할 있습니다.

세부 정보

에이전트는 수직으로 서 있는 실린더에 의해 정의되며 실린더의 크기는 Radius  Height 프로퍼티에 의해 특정됩니다. 실린더는 오브젝트와 함께 움직이지만 오브젝트가 회전한다 해도 계속 수직으로 서 있습니다. 실린더의 모양은 다른 에이전트와 장애물간의 충돌 감지와 대응에 사용됩니다. 게임 오브젝트의 앵커 포인트가 실린더의 베이스에 없을 때 높이의 차이를 메우기 위해 베이스 오프셋 프로퍼티를 사용할 수 있습니다.

실린더의 높이와 반경은 실제로 두 개 의 다른 장소에 특정됩니다. 내비메시 베이크 설정과 각 에이전트의 프로퍼티 입니다.

내비메시 베이크 설정 은 내비메시 에이전트가 어떻게 정적인 월드 지오메트리와 충돌하고 또는 어떻게 회피하는지를 설명합니다. 메모리 여유량을 유지하고 CPU 로드를 지속적으로 체크하기 위해 오직 하나의 크기만이 베이크 설정에서 특정될 수 있습니다. -내비메시 에이전트 프로퍼티 값은 에이전트가 움직이는 장애물 및 다른 에이전트와 어떻게 충돌하는지를 설명합니다.

대부분의 경우 두 장소 모두에 에이전트 크기를 똑같이 설정합니다. 하지만 예를 들어 크기가 큰 에이전트의 반경이 더 넓다면 다른 에이전트는 그 에이전트 주변에 공간을 더 많이 남겨둡니다. 하지만 그렇지 않다면 크기가 큰 에이전트도 마찬가지로 환경을 무시합니다.

 

하이라키뷰에서 Player를 선택하고  TAG에서 PLAYER를 생성하고 적용한다

Nave Mesh Agent는 인스펙터에서 추적대상을 연결할 수 없고 스크립트를 생성해야한다.

MonsterCtrl스크립트를 생성하자.  Monster에 적용한다.

코드는 간단한다 다음한줄로 자동으로 추적한다. 다만 명령을 실행할 당시의 위치만이다. 명령실행후 플레이어가 움직이면 자동 갱신되지 않는다.

agent.destination = playerTr.position;

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;  //for Navigation

public class MonsterCtrl : MonoBehaviour
{
    private Transform monsterTr;
    private Transform playerTr;
    private NavMeshAgent agent;
    void Start()
    {
        monsterTr= GetComponent<Transform>();
        playerTr = GameObject.FindWithTag("PLAYER").GetComponent<Transform>();  
        agent = GetComponent<NavMeshAgent>();
        agent.destination = playerTr.position;  //목표를 플레이어로 설정
    }
}

잘 된다. 처음에는 PLAYER TAG를 Monster에도 설정했다가 뺑글뺑글 도는 걸 보고 풀어줬더니 잘되었다. PLAYER TAG는 Player에만

 

Monster는 NavMeshAgent가 미리 베이크된 NavMesh에 붙어서 이동하기 때문에 살짝 떠 있다. 방법은 NavMesh를 Bake할대 낮춰주던지 Monster NavMeshAgent의 BaseOffset을 -0.1로 낮춰주는 거다

오른쪽 BaseOffset이 -0.1 로 적용되어 있음
Monster가 NavMesh밑으로 가라앉어있다.

 

적 캐릭터는 NPC(Non Playable Character)의 일종으로 게임에 참여한 플레이어가 조작할 수 없는 캐릭터를 말한다.

 

유한 상태 머신의 정의

NPC의 인공지능을 구현하는 방식에는 여러가지가 있다. 그중 하나가 유한상태머신(FSM:Finite State Machine)으로 적캐릭터가 스스로 알아서 주변 환경에 적응하거나 들어오는 반응에 적절하게 반작용하도록 구현한 것을 말한다. 

적 캐릭터가 아래와 같이 Spwan되고 일정 범위를 순찰하다가 주인공을 추적하고 사정거리 이내에 근접하면 공격하는 것과 같은 구조를 상태머신이라고 한다. 또한 일정 데미지가 누적되며 사망하고 소멸되는 구조이기에 상태가 유한하여 유한상태머신 이라고 부른다.

상태가 많아 질수록 유지보수가 어려워진다. 이런점을 개선하기 위해 모듈화하고 계층적으로 분류하는 계층적 유한상태머신과 행동트리방식도 개발에 많이 적용한다.

 

메카님

FSM의 상태에 따라  적절한 애니메이션 동작을 취할 수 있게 메카님을 사용해 유연한 애니메이션을 제어해보자.

메카님은 리타게팅 기능을 제공해 Humanoid 모델의 필수 본 구조와 일치하면 다른 모델의 애니메이션을 바로 적용할 수 있다.

 

적캐릭터 3D 모델 임포트

다운한 Resources/Models폴더에서 Monster피키지를 임포트해 프로젝트의 05.Models폴더로 옮긴다.

Monster아이콘 삼각형을 눌러 펼쳐보면 삼각형아이콘이 애니메이션이다. idel, walk, attack,die, fall, gothit등 기본적인 애니메이션이 포함되어 있다.

메카님 애니메이션으로 전화

인스펙터뷰의 Rig탭을 눌러보면 Animation Type이 Generic으로 설정되어 있다.

애니메이션은 레거시, 제너릭, 휴머노이드 3가지 유형이 있으며,  각 타입특성은 다음과 같다.

레거시 : 유니티 4.0이전 버전

제너릭 : 메카님 애니메이션(비인간형모델), 리타게팅 불가

휴머노이드 : 메카님 애니메이션(인간형모델), 리타게팅 가능

 

Monster는 2족 보행(Biped) 모델로 휴머노이드로 설정해 구현할 수 있다. 애니메이션 타입을 Humanoid로 변경후 하단의 Apply후 자동 매핑이 되고 Configure버튼이 활성화 된다. 실패하면 x표시가 나타나고 수동으로 본을 연결해야 한다.

 

Config 버튼을 누르면 Bone구조의 매핑 정보를 확인할 수 있는 Avatar Confg화면이 나타난다. Avatar  Asset은 모델+본구조매핑정보로 본 구조가 동일하다면 재사용이 가능하다.

실선으로 돼 있는 원은 필수로 15개가 있으며 점선은 추가적인 본으로 연결하지 않아도 상관없다. 

 

인스펙트뷰의 좌하 Head탭을 누르면 머리의 본 연결 정보를 볼수 있고 Left Hand를 누르면 손가락 관절의 정보를 볼 수도 있다.

Monster Avatar상단의 Muscles&Settings 탭을 선택하면 각 관절의 정상적인 동작 여부를 슬라이더로 조절하며 시각적으로 확인할 수 있다. Pre-Muscle Settings에서  각 관절의 회전 범위를 설정할 수 있다.

맨밑의 Revert를 눌러 만질걸 복구시키고 Done을 눌러 변경을 마친다. 다시  인스펙터창이 보이고 Animation탭을 선택한다. idel, walk,attack 애니메이션만 Loop Time을 체크한후 Apply버튼을 누른다

애니메이션 리타게킹

맥사모 www.maxamo.com  사이트에 가입후 무료 에니메이션을 가져오자.

사이트에서 gangnam으로 검색후 싸이의 강남스타일 애니메이션을 가져오자.

지금 작업중인 적 캐릭터 모델파일(Monster.fbx)를 업로드해 애니메이션을 테스트도 할 수 있다.

Resources/Animations 폴더에 미리 다운받은 monster@Gangnam Style.fbx파일이 있으니 이걸 프로젝트뷰의 07.Animation폴더로 끌어온다.

메카님 리타게팅을 사용하려면 적용하려는 모델과 애니메이션 클립의 Type이 둘다 Humanoid로 설정해야 한다. 따라서 monster@Gangnam Style.fbx도 Rig탭에서 타입을 Humanoid로 바꾸고 apply한다. Animation탭을 누루고  Loop Time도 체크후 Apply해 준다.

같은 Animation탭 맨밑의 프리뷰창에 05.Model/Monster폴더의 Monster를 끌어다 놓고 Play시켜보면 춤을 추는걸 확인할수 있다.

Animator 컴포넌트

Monster모델을 씬뷰또는 하이라키로 끌어다 Player옆에 놓는다. 

씬뷰에 추가된  Monster는 기본적으로 Animator가 추가되어 있다. 3D모델의 애니메이션  타입을 메카님으로 설정하면 Animator컴포넌트가 기본적으로 추가된다.  앞서 제작한 Player모델은 애니메이션 타입을 레거시로 설정했기 때문에 Animation 컴포넌트가 추가된것이다. Animator는 Animation을 컨트롤 하는 컴포넌트로 레가시는 Animation컴포넌트가 그 역활을 한다.

Animation컴포넌트의 각 속성의 기능은 다음과 같다.

프로퍼티: 기능:
Controller 캐릭터에 부여된 애니메이터 컨트롤러.
Avatar 캐릭터의 Avatar입니다(애니메이터가 휴머노이드 캐릭터를 애니메이션화하기 위해 사용되는 경우).
Apply Root Motion 캐릭터의 포지션과 회전을 애니메이션 자체에서 제어할지 또는 스크립트에서 제어할지 선택합니다.
Update Mode 애니메이터 업데이트를 언제 할지, 어떤 타임스케일을 사용할지 선택할 있게 해줍니다.
  Normal 애니메이터는 업데이트 호출과 싱크되어 업데이트되며, 애니메이터의 속도는 현재의 타임스케일과 일치합니다. 타임스케일이 느려지면 애니메이션도 따라서 느려집니다.
  Animate Physics 애니메이터는 FixedUpdate 호출에 따라(, 물리 시스템과 동시에) 업데이트됩니다. 리지드바디 오브젝트를 밀어 움직일 있는 캐릭터와 같이 물리 상호작용을 하는 오브젝트의 모션을 애니메이션화하는 경우에 모드를 사용해야 합니다.
  스케일되지 않은 시간(Unscaled Time) 애니메이터는 업데이트 호출에 따라 업데이트되지만 애니메이터의 속도는 현재의 타임스케일을 무시하고 항상 100% 속도로 애니메이션화합니다. 게임플레이를 일시정지하거나 특수한 효과를 위해 수정된 타임스케일을 사용하면서 GUI 시스템을 노멀 속도로 애니메이션화하는 경우에 유용합니다.
Culling Mode 애니메이션에 사용할 있는 컬링 모드.
  항상 애니메이션화(Always Animate) 항상 애니메이션화하고 오프스크린일 경우에도 컬링을 하지 않습니다.
  업데이트 트랜스폼 컬링(Cull Update Transforms) 렌더러가 보이지 않을 리타겟, IK 트랜스폼의 쓰기를 비활성화합니다.
  완전히 컬링(Cull Completely) 렌더러가 보이지 않을 애니메이션을 완전히 비활성화합니다.

 

애니메이터 컨트롤러

애니메이터 컨트롤러를 사용하여 캐릭터나 오브젝트의 애니메이션 클립 세트와 관련 애니메이션 전환을 정렬하고 관리할 수 있습니다. 대부분의 경우, 여러 애니메이션을 이용하여 게임 내에서 특정 상황이 일어났을 때 에셋을 바꿔가면서 사용합니다. 예를 들어 스페이스바를 누를 때마다 걷기 애니메이션 클립에서 점프 애니메이션 클립으로 전환할 수 있습니다. 하지만 애니메이션 클립이 하나만 있을 때에도 이 클립을 게임 오브젝트에 사용하기 위해서는 애니메이터 컨트롤러에 배치해야 합니다.

애니메이터 컨트롤러는 그 안에서 사용되는 애니메이션 클립에 대한 레퍼런스를 포함하고 있으며, 애니메이션 클립과 전환의 순서도 또는 Unity 에디터 내에서 비주얼 프로그래밍 언어로 작성된 간단한 프로그램이라고 생각할 수 있는 상태 머신 을 사용하여 다양한 애니메이션 클립과 각 클립 사이의 전환을 관리합니다. 상태 머신에 대한 자세한 내용은 여기에서 확인할 수 있습니다.

프로젝트뷰의 07.Animation폴더 하위에 Controllers라는 서브폴더를 생성하고 Assts>Create>Animator Controller를 선택해 이름을 MonsterAnim으로 지정한다.

생성한 MonsterAnim을 더블클릭하면 다음 같은 애니메이터뷰가 열린다.

BaseLayer에 3개의 State가 있다.

Any State : 어떤 상태라도 조건이 맞으면 다른 스테이트로 분기시킬 수 있다.

Entry : 시작 스테이트 최초 진입점

Exit : 종료 스테이트 모든 스테이트가 종료되는 마지막 스테이트

Models>Monster폴더의 Monster를 눌러 삼각형 아이콘의 Idle 을 끌어다  애니메이터뷰에 끌어다 놓으면

다음 처럼된다 추가된 Idle은 Default를 의미하는 주황색을 띤다. 다른 스테이트도 우클릭으로 Default State로 변경가능.

하이라키뷰의 Monster를 선택하고 Animation>Controller폴더의 MonsterAnim를 Animator컴포넌트의 Controller에 끌어다 놓는다.

실행해보면 Idel에 파란색 진행 상태가바 채워지는 모습을 확인할 수 있다. 한번만 실행된다면 Loop를 체크해야한다.

이제 Walk도 끌어다 놔보자 아이들을 우클릭후 Maker Transition선택하면 화살표가 나오는데 이걸 walk에 연결한다. 이선을 transition이라고 한다.

애니메이터의 좌상 Parameters 탭을 선택하고 +를 눌러 Bool을 선택하고 이름을 IsTrace로 지정한다.

새로만든 IsTrace변수가 true면 walk, false면 Idel로 되돌아오게 해보겠다.

Idel과 walk사이의 transition을 선택하고 인스펙터뷰의 Conditions속성에 +를 눌러 조건을 추가하면 IsTrue가 true로 되어있다.   HasExitTime은 언체크한다. 이건 현재실행중인 애니메이션을 즉시 멈추고 실행한다는 뜻이다.

Idle>Walk, Walk>Idle transition 설정

이번에는 walk를 우클릭한후 transition을Idel에 연결해주고 transition을 눌러 IsTrace 를 fasle로 한다. 역시 HasExitTime은 언체크한다.

Play를 누르고 게임뷰에서 Animator탭으로 전환하고 왼쪽의 IsTrace를 체크/언체크해보면  스테이지가 변화한다.

 

적 캐릭터는 NPC(Non Playable Character)의 일종으로 게임에 참여한 플레이어가 조작할 수 없는 캐릭터를 말한다.

 

유한 상태 머신의 정의

NPC의 인공지능을 구현하는 방식에는 여러가지가 있다. 그중 하나가 유한상태머신(FSM:Finite State Machine)으로 적캐릭터가 스스로 알아서 주변 환경에 적응하거나 들어오는 반응에 적절하게 반작용하도록 구현한 것을 말한다. 

적 캐릭터가 아래와 같이 Spwan되고 일정 범위를 순찰하다가 주인공을 추적하고 사정거리 이내에 근접하면 공격하는 것과 같은 구조를 상태머신이라고 한다. 또한 일정 데미지가 누적되며 사망하고 소멸되는 구조이기에 상태가 유한하여 유한상태머신 이라고 부른다.

상태가 많아 질수록 유지보수가 어려워진다. 이런점을 개선하기 위해 모듈화하고 계층적으로 분류하는 계층적 유한상태머신과 행동트리방식도 개발에 많이 적용한다.

 

메카님

FSM의 상태에 따라  적절한 애니메이션 동작을 취할 수 있게 메카님을 사용해 유연한 애니메이션을 제어해보자.

메카님은 리타게팅 기능을 제공해 Humanoid 모델의 필수 본 구조와 일치하면 다른 모델의 애니메이션을 바로 적용할 수 있다.

 

적캐릭터 3D 모델 임포트

다운한 Resources/Models폴더에서 Monster피키지를 임포트해 프로젝트의 05.Models폴더로 옮긴다.

Monster아이콘 삼각형을 눌러 펼쳐보면 삼각형아이콘이 애니메이션이다. idel, walk, attack,die, fall, gothit등 기본적인 애니메이션이 포함되어 있다.

메카님 애니메이션으로 전화

인스펙터뷰의 Rig탭을 눌러보면 Animation Type이 Generic으로 설정되어 있다.

애니메이션은 레거시, 제너릭, 휴머노이드 3가지 유형이 있으며,  각 타입특성은 다음과 같다.

레거시 : 유니티 4.0이전 버전

제너릭 : 메카님 애니메이션(비인간형모델), 리타게팅 불가

휴머노이드 : 메카님 애니메이션(인간형모델), 리타게팅 가능

 

Monster는 2족 보행(Biped) 모델로 휴머노이드로 설정해 구현할 수 있다. 애니메이션 타입을 Humanoid로 변경후 하단의 Apply후 자동 매핑이 되고 Configure버튼이 활성화 된다. 실패하면 x표시가 나타나고 수동으로 본을 연결해야 한다.

 

Config 버튼을 누르면 Bone구조의 매핑 정보를 확인할 수 있는 Avatar Confg화면이 나타난다. Avatar  Asset은 모델+본구조매핑정보로 본 구조가 동일하다면 재사용이 가능하다.

실선으로 돼 있는 원은 필수로 15개가 있으며 점선은 추가적인 본으로 연결하지 않아도 상관없다. 

 

인스펙트뷰의 좌하 Head탭을 누르면 머리의 본 연결 정보를 볼수 있고 Left Hand를 누르면 손가락 관절의 정보를 볼 수도 있다.

Monster Avatar상단의 Muscles&Settings 탭을 선택하면 각 관절의 정상적인 동작 여부를 슬라이더로 조절하며 시각적으로 확인할 수 있다. Pre-Muscle Settings에서  각 관절의 회전 범위를 설정할 수 있다.

맨밑의 Revert를 눌러 만질걸 복구시키고 Done을 눌러 변경을 마친다. 다시  인스펙터창이 보이고 Animation탭을 선택한다. idel, walk,attack 애니메이션만 Loop Time을 체크한후 Apply버튼을 누른다

애니메이션 리타게킹

맥사모 www.maxamo.com  사이트에 가입후 무료 에니메이션을 가져오자.

사이트에서 gangnam으로 검색후 싸이의 강남스타일 애니메이션을 가져오자.

지금 작업중인 적 캐릭터 모델파일(Monster.fbx)를 업로드해 애니메이션을 테스트도 할 수 있다.

Resources/Animations 폴더에 미리 다운받은 monster@Gangnam Style.fbx파일이 있으니 이걸 프로젝트뷰의 07.Animation폴더로 끌어온다.

메카님 리타게팅을 사용하려면 적용하려는 모델과 애니메이션 클립의 Type이 둘다 Humanoid로 설정해야 한다. 따라서 monster@Gangnam Style.fbx도 Rig탭에서 타입을 Humanoid로 바꾸고 apply한다. Animation탭을 누루고  Loop Time도 체크후 Apply해 준다.

같은 Animation탭 맨밑의 프리뷰창에 05.Model/Monster폴더의 Monster를 끌어다 놓고 Play시켜보면 춤을 추는걸 확인할수 있다.

Animator 컴포넌트

Monster모델을 씬뷰또는 하이라키로 끌어다 Player옆에 놓는다. 

씬뷰에 추가된  Monster는 기본적으로 Animator가 추가되어 있다. 3D모델의 애니메이션  타입을 메카님으로 설정하면 Animator컴포넌트가 기본적으로 추가된다.  앞서 제작한 Player모델은 애니메이션 타입을 레거시로 설정했기 때문에 Animation 컴포넌트가 추가된것이다. Animator는 Animation을 컨트롤 하는 컴포넌트로 레가시는 Animation컴포넌트가 그 역활을 한다.

Animation컴포넌트의 각 속성의 기능은 다음과 같다.

프로퍼티: 기능:
Controller 캐릭터에 부여된 애니메이터 컨트롤러.
Avatar 캐릭터의 Avatar입니다(애니메이터가 휴머노이드 캐릭터를 애니메이션화하기 위해 사용되는 경우).
Apply Root Motion 캐릭터의 포지션과 회전을 애니메이션 자체에서 제어할지 또는 스크립트에서 제어할지 선택합니다.
Update Mode 애니메이터 업데이트를 언제 할지, 어떤 타임스케일을 사용할지 선택할 있게 해줍니다.
  Normal 애니메이터는 업데이트 호출과 싱크되어 업데이트되며, 애니메이터의 속도는 현재의 타임스케일과 일치합니다. 타임스케일이 느려지면 애니메이션도 따라서 느려집니다.
  Animate Physics 애니메이터는 FixedUpdate 호출에 따라(, 물리 시스템과 동시에) 업데이트됩니다. 리지드바디 오브젝트를 밀어 움직일 있는 캐릭터와 같이 물리 상호작용을 하는 오브젝트의 모션을 애니메이션화하는 경우에 모드를 사용해야 합니다.
  스케일되지 않은 시간(Unscaled Time) 애니메이터는 업데이트 호출에 따라 업데이트되지만 애니메이터의 속도는 현재의 타임스케일을 무시하고 항상 100% 속도로 애니메이션화합니다. 게임플레이를 일시정지하거나 특수한 효과를 위해 수정된 타임스케일을 사용하면서 GUI 시스템을 노멀 속도로 애니메이션화하는 경우에 유용합니다.
Culling Mode 애니메이션에 사용할 있는 컬링 모드.
  항상 애니메이션화(Always Animate) 항상 애니메이션화하고 오프스크린일 경우에도 컬링을 하지 않습니다.
  업데이트 트랜스폼 컬링(Cull Update Transforms) 렌더러가 보이지 않을 리타겟, IK 트랜스폼의 쓰기를 비활성화합니다.
  완전히 컬링(Cull Completely) 렌더러가 보이지 않을 애니메이션을 완전히 비활성화합니다.

 

애니메이터 컨트롤러

애니메이터 컨트롤러를 사용하여 캐릭터나 오브젝트의 애니메이션 클립 세트와 관련 애니메이션 전환을 정렬하고 관리할 수 있습니다. 대부분의 경우, 여러 애니메이션을 이용하여 게임 내에서 특정 상황이 일어났을 때 에셋을 바꿔가면서 사용합니다. 예를 들어 스페이스바를 누를 때마다 걷기 애니메이션 클립에서 점프 애니메이션 클립으로 전환할 수 있습니다. 하지만 애니메이션 클립이 하나만 있을 때에도 이 클립을 게임 오브젝트에 사용하기 위해서는 애니메이터 컨트롤러에 배치해야 합니다.

애니메이터 컨트롤러는 그 안에서 사용되는 애니메이션 클립에 대한 레퍼런스를 포함하고 있으며, 애니메이션 클립과 전환의 순서도 또는 Unity 에디터 내에서 비주얼 프로그래밍 언어로 작성된 간단한 프로그램이라고 생각할 수 있는 상태 머신 을 사용하여 다양한 애니메이션 클립과 각 클립 사이의 전환을 관리합니다. 상태 머신에 대한 자세한 내용은 여기에서 확인할 수 있습니다.

프로젝트뷰의 07.Animation폴더 하위에 Controllers라는 서브폴더를 생성하고 Assts>Create>Animator Controller를 선택해 이름을 MonsterAnim으로 지정한다.

생성한 MonsterAnim을 더블클릭하면 다음 같은 애니메이터뷰가 열린다.

BaseLayer에 3개의 State가 있다.

Any State : 어떤 상태라도 조건이 맞으면 다른 스테이트로 분기시킬 수 있다.

Entry : 시작 스테이트 최초 진입점

Exit : 종료 스테이트 모든 스테이트가 종료되는 마지막 스테이트

Models>Monster폴더의 Monster를 눌러 삼각형 아이콘의 Idle 을 끌어다  애니메이터뷰에 끌어다 놓으면

다음 처럼된다 추가된 Idle은 Default를 의미하는 주황색을 띤다. 다른 스테이트도 우클릭으로 Default State로 변경가능.

하이라키뷰의 Monster를 선택하고 Animation>Controller폴더의 MonsterAnim를 Animator컴포넌트의 Controller에 끌어다 놓는다.

실행해보면 Idel에 파란색 진행 상태가바 채워지는 모습을 확인할 수 있다. 한번만 실행된다면 Loop를 체크해야한다.

이제 Walk도 끌어다 놔보자 아이들을 우클릭후 Maker Transition선택하면 화살표가 나오는데 이걸 walk에 연결한다. 이선을 transition이라고 한다.

애니메이터의 좌상 Parameters 탭을 선택하고 +를 눌러 Bool을 선택하고 이름을 IsTrace로 지정한다.

새로만든 IsTrace변수가 true면 walk, false면 Idel로 되돌아오게 해보겠다.

Idel과 walk사이의 transition을 선택하고 인스펙터뷰의 Conditions속성에 +를 눌러 조건을 추가하면 IsTrue가 true로 되어있다.   HasExitTime은 언체크한다. 이건 현재실행중인 애니메이션을 즉시 멈추고 실행한다는 뜻이다.

Idle>Walk, Walk>Idle transition 설정

이번에는 walk를 우클릭한후 transition을Idel에 연결해주고 transition을 눌러 IsTrace 를 fasle로 한다. 역시 HasExitTime은 언체크한다.

Play를 누르고 게임뷰에서 Animator탭으로 전환하고 왼쪽의 IsTrace를 체크/언체크해보면  스테이지가 변화한다.

 

총구 화염 효과는 파티클 효과나 성능을 고려해 가벼운 메시를 사용할 수 있다. 

하이라키뷰의 Player하위에 있는 FirePos를 선택하고 우클릭후 팝업 메뉴에서 Create>3D Object>Quad를 선택한다. 이름을 MuzzleFlash로 수정한다. 발사되는 총알의 충돌을 막기위해 Mesh Collider 컴포넌트는 삭제한다.

총구 화염은 실시간 그림자가 필요없으므로 Mesh Renderer의 Cast Shdows를 Off로 설정라고 조명과 관련된 Receive Shadows를 Uncheck한다. 내경우는 Disable되어 있어 끌수가 없었다. 이상함..

다운받았던 Resources>Texture>Wepons폴더의 MuzzleFlash.png파일을 끌어다 05.Images 폴더로 드래그해 임포트한다.

이 이미지는 4개의 화염이미지가 들어 있지만 4등분해서 랜덤하게 하나만 사용한다.

MuzzleFlash.png를 끌어다 MuzzleFlash에 적용한고 Shader를 Mobile/Particles/Additive로 하면 검은부분이 투명하게 바낀다.

Shader 왼쪽 삼각형을 눌러 메뉴를 펴서 Particle Texture Tiling을 0.5, 0.5로 하면 4장으로 자르게 된다.

밑의 offet을 변경해 4장중 한장을 고를 수 있다.

주인공 캐릭터 하위의 MuzzleFlash에 포한된 MeshRenderer 컴포넌트를 추출하는 로직을 FireCtrl스크립트에 추가한다. MuzzleFlash의 Offset값을 변경하려면 머터리얼 정보를 담고 있는 MeshRenderer컴포넌트에 접근해야 한다. FireCtrl에 이미 있는 firePos변수를 이용해서 접근한후 비활성화 한다.  총을 발사할때문 보이게 할것이다.

muzzleFlash = firePos.GetComponentInChildren<MeshRenderer>();

muzzleFlash.enabled = false;

실행해보면 MuzzleFlash의 MeshRenderer컴포넌트가 비활성활되어 있다.

Play옆 포즈를 누르고 씬뷰탭을 선택후 ALT+좌클릭후 마우스를 움직여 씬뷰를 회전시키면 총구의 화염이 없는 것도 확인할수 있다

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
[RequireComponent(typeof (AudioSource))]  //삭제되는것을 방지하는 어트리뷰트
public class FireCtrl : MonoBehaviour
{
    public GameObject bullet;  //총알프리팹
    public Transform firePos;  // 총알 발사 좌표
    public AudioClip fireStx;  //총소리에 사용할 음원
    private new AudioSource audio;  //오디오 소스 컴포넌트를 저장할 변수
    private MeshRenderer muzzleFlash;  //머즐이펙트 참조
    // Update is called once per frame
    private void Start() {
        audio = GetComponent<AudioSource>();  //오디오소스를 얻어온다
        muzzleFlash = firePos.GetComponentInChildren<MeshRenderer>();  //자식 컴포넌트를 얻어온다
        muzzleFlash.enabled = false;  //안보이게한다
    }
    void Update()
    {  //마우스 왼쪽 버튼을 클릭했을때  Fire 함수 호출
        if(Input.GetMouseButtonDown(0)) {
            Fire();  //발사처리
        }
    }
    void Fire() {
        Instantiate(bullet,firePos.position, firePos.rotation); //Bullet프리팹을 동적으로 발생
        audio.PlayOneShot(fireStx, 1.0f);  //총소리발생
    }
}

코루틴함수

유니티의 모든 스크립트는 메시지루트가 동작한다. 다양한 이벤트함수가 정해진 순서대로 실행되는 순환구조이다. 일반 함수를 호출하면 해당 함수 안의 로직이 다 수행해야만 실행이 끝난다. 

함수안에서 로직이 10초 걸린다고 하면10초동안 메시지루프의 다른 로직을 기다리게 하는데 이걸 Blocking이라고 한다. 

이를 개선하려면 함수를 병렬로 호출해야한다. 이걸 Multi thread라고 한다. 

유니티는 코루틴이라는 병렬처리기능을 제공한다. 

만일 Fade()라는 함수는 for문을 통해 알파값을 감소시켜 투명처리하는 함수인데 순식간에 처리하므로 효과를 확인할수 없어 갑자기 꺼지게 될것이다.

void Fade() {
    for(float f = 1f; f>=0; f-= 0.1f) {
       Color c = GetComponent<Renderer>().material.color;
       c.a = f;  //알파값을 천천히 감소시킨다
       GetComponent<Renderer>().material.color = c;
    }
}

코루틴의 아이디어는 yield return null; 키워드를 만나면 제어 권한을 유니티시스템의 메시지루프로 양보하는 방식이다.

코루틴 함수는 열거자 IEnumerator 타입으로 선언해야 하고 함수내에 하나이상의 yeild 키워드를 사용해야 한다.

Ienumerate void Fade() {
    for(float f = 1f; f>=0; f-= 0.1f) {
       Color c = GetComponent<Renderer>().material.color;
       c.a = f;  //알파값을 천천히 감소시킨다
       GetComponent<Renderer>().material.color = c;
       yield return null;  //메시지루프로 제어권을 넘긴다.
    }
}

yield return null;키워드는 함수내에 여러개 삽입될 수도 있다.

코루틴 함수는 호출시 StartCoroutine()함수를 이용하여 호출한다. 전달인자는 함수의 원형(포인터)를 권장한다. "함수명"으로 호출하는건 가비지컬렉션을 발생시키 개별적으로 정지시킬수 없기 때문이다.

StartCoroutine(Fade());

yield return null대신 yield return WaitForSecond(0.3f)같이 하면 자기 자신을 0.3초동안 멈추게 할 수 있다. 대신 로직을 잘짜 무한 루프에 안 빠지게 해야한다. 다음은 적이 죽지 않았다면 0.3초간 기다려주는 로직이다. 만일 yield return new WaitForSeconds(0.3f)가 없다면 정말 무한루프에 빠질것이다.

bool isDie;
IEnumerator CheckState() {
    while(!isDie)  {  //죽지않았다면
        yield return new waitForSecond(0.3f); //3초 대기하기
    }
}

MuzzleFlash의 블링크 효과

총알알 발사할때 MuzzleFlash가 깜빡거리는 Blink효과를 구현해보자.  다음과 같이 하면 순식간에 일어나 효과가 없다

void ShowMuzzleFlash() {  //일반함수
    muzzleFlash.enabled = true;  //MuzzleFlash활성화
    muzzleFlash.enable = false; //MuzzleFlash비활성화
}

Blink효과는 점멸을 천천히 반복해야 하므로 코루틴을 사용해야한다.

Ienumerator void ShowMuzzleFlash() { //코루틴
    muzzleFlash.enabled = true; //MuzzleFlash활성화
    yield return new WaitForSecond(0.2f);
    muzzleFlash.enable = false;  //MuzzleFlash비활성화
}

MuzzleFlash의 텍스처 오프셋 변경

MuzzleFlash에 연결된 텍스처의 오프셋을 불규칙하게 변경해보자 또한 각도및 스케일도 조절해보자.

텍스처 오프셋값은 SetTextureOffset함수의 mainTextureOffset속성으로 설정할 수 있다.

처음 tiling걊을 (0.5,0.5)로 했기때문에 오프셋값은 다음 4개중 하나이다. (0,0), (0,0.5), (0.5,0), (0.5,0.5)

Randome.Range(0,2)가 리턴하는 숫자는 0, 1이므로 이값에 0.5f를 곱하여 이용하면 된다.

Vector2 offset = New Vector2(Randome.Range(0,2), Randome.Range(0,2))*0.5f;
muzzleFlash.material.mainTextureOffset = offset;
//muzzleFlash.material.setTextureOffset("_MainTxt",offset);  //이렇게도 가능하다

MuzzleFlash는 다른 게임오브젝트(FirePos)아래에 차일드화된 게임오브젝트로서, 좌표, 회전, 각도, 스케일을 수정하려면 반드시 localPosition, localRotation, localScale속성을 사용해야 한다.

MuzzleFlash를 회전시키기 위한 localRotation속성은 Quaternion 타입이므로 오일러각을 변화시키는 Quaternion.Euler(x,y,x)함수를 사용한다. 0~360도의 난수를 발생시켜 회전시켜본다. 유니티 원시모델 Quad로 만들어진 MuzzleFlash는 x축으로 -90도 회전된 모델로 z축을 기준으로 회전시켜야 하므로

float angle = Random.Range(0,360);
muzzleFlash.transfor.localRotation = Quaternion.Euler(0,0,angle);

다음과 같이 단위벡터를 사용할수도 있다.  z축은 Vector3.forward를 사용해야한다.

float angle = Random.Range(0,360);
muzzleFlash.transfor.localRotation = Quaternion.Euler(Vector3.forward*angle);

localScale을 이용해 사이즈를 조절한다. Vector3.one은 Vector3(1,1,1)을 나타내는 상수이다.

 

float scale = Random.Range(1.0f,2.0f);
muzzleFlash.transfor.localScale = Vector3.one * scale;

다음과 같이 FireCtrl을 변경하고 실행해보면 MuzzleFlash의 텍스처 크기 회전이 불규칙하게 변하는 것을 확인할 수 있다.

각도 때문에 게임뷰에서 확인이 어렵다면 게임뷰와 씬뷰를 동시에 보면서 할수 있다. 씬뷰는 게임중에도 위치와  회전을 할 수 있다.

왼쪽:씬뷰 어른쪽:게임뷰

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
[RequireComponent(typeof (AudioSource))]  //삭제되는것을 방지하는 어트리뷰트
public class FireCtrl : MonoBehaviour
{
    public GameObject bullet;  //총알프리팹
    public Transform firePos;  // 총알 발사 좌표
    public AudioClip fireStx;  //총소리에 사용할 음원
    private new AudioSource audio;  //오디오 소스 컴포넌트를 저장할 변수
    private MeshRenderer muzzleFlash;  //머즐이펙트 참조
    // Update is called once per frame
    private void Start() {
        audio = GetComponent<AudioSource>();  //오디오소스를 얻어온다
        muzzleFlash = firePos.GetComponentInChildren<MeshRenderer>();  //자식 컴포넌트를 얻어온다
        muzzleFlash.enabled = false;  //안보이게한다
    }
    void Update()
    {  //마우스 왼쪽 버튼을 클릭했을때  Fire 함수 호출
        if(Input.GetMouseButtonDown(0)) {
            Fire();  //발사처리
        }
    }
    void Fire() {
        Instantiate(bullet,firePos.position, firePos.rotation); //Bullet프리팹을 동적으로 발생
        audio.PlayOneShot(fireStx, 1.0f);  //총소리발생\
        StartCoroutine(ShowMuzzleFlash()); //총구화염효과 코루틴함수 호출
     
    }
    IEnumerator ShowMuzzleFlash() {  //코루틴
        Vector2 offset = new Vector2(Random.Range(0, 2), Random.Range(0, 2)) * 0.5f;
        muzzleFlash.material.mainTextureOffset = offset;  //4장중 한장을 선택
        float angle = Random.Range(0, 360);
        muzzleFlash.transform.localRotation = Quaternion.Euler(0,0, angle);  //로컬회전, 쿼터니언으로 변경할것
        float scale = Random.Range(1.0f, 2.0f);
        muzzleFlash.transform.localScale = Vector3.one * scale;  //로컬스케일변경
        muzzleFlash.enabled=true; //muzzleFlash 활성화
        yield return new WaitForSeconds(0.2f);  // new를 붙여줘야함. 권한을 0.2초 넘겨줌
        muzzleFlash.enabled=false;  //muzzleFlash 비활성화
    }
}

코루틴의 응용 -  임계치

게임을 실행하면 Player의 방향이 불규칙하다. 이건 마우스의 좌우 이동값이 불규칙하게 넘어오기 때문이다. Start함수를 코루틴으로 변경시켜 해결해보자. Player스크립트를 다음과 같이 수정한다. 교재는 PlayerCtrl이다.

using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEditor;
using UnityEngine;

public class Player : MonoBehaviour {
    // Start is called before the first frame update
    Transform tr;
    private float moveSpeed=10f;
    private float turnSpeed=0f;
    private Animation anim;

    IEnumerator  Start() {  //start()함수는 코루틴으로 실행할 수 있다.
        tr = GetComponent<Transform>();
        anim = GetComponent<Animation>();  //추가된 코드
        anim.Play("Idel");
        yield return new WaitForSeconds(0.3f);
        turnSpeed = 500f;
    }

    // Update is called once per frame
    void Update() {
        float h = Input.GetAxis("Horizontal");  //AD 입력 좌우
        float v = Input.GetAxis("Vertical");  //WS 입력 전후
        if (Mathf.Abs(h) > float.Epsilon || Mathf.Abs(v) > float.Epsilon) {  //움직임이 없다면 불필요한 동작을 안한다.
            Vector3 dir = Vector3.right * h + Vector3.forward * v;
            tr.Translate(dir.normalized * moveSpeed * Time.deltaTime);
            PlayerAnim(h, v);
        }
        float r = Input.GetAxis("Mouse X");  //마우스 x축 입력 
        tr.Rotate(Vector3.up * turnSpeed * Time.deltaTime * r);
    }
    void PlayerAnim(float h, float v) {
        Debug.Log("Move");
        if (v >= 0.1f) {
            anim.CrossFade("RunF",0.25f);
        } else if(v <= -0.1f) {
            anim.CrossFade("RunB", 0.25f);
        } else if(h >= 0.1f) {
            anim.CrossFade("RunR", 0.25f);
        } else if (h <= -0.1f) {
            anim.CrossFade("RunL", 0.25f);
        } else {
            anim.CrossFade("Idle", 0.25f);
        }
    }
}

들어가며

Unity의 Physics.Raycast는 직선을 씬에 투영하여 대상에 적중되면 true를 리턴하는 물리 함수다. Raycast 함수는 캐스팅 성공 실패에 따른 결과만 리턴하는 간단한 형태에서 부터 대상과 Ray의 충돌에 관련된 자세한 정보를(직선과 객체의 교차 정보. 거리, 위치, 캐스팅에 검출 된 객체의 Transform에 대한 참조 등) 리턴하는 다양한 버전이 제공 되고 있다.

이번 포스트에서는 Raycast 함수를 사용하기 위해 알아야할 필수적인 요소들을 살펴 보는 시간을 갖도록 하겠다.

Unity에서 Raycast를 사용하는 법

Unity 2020.3 버전 기준으로 Physics.Raycast는 아래와 같이 다양한 버전으로 오버로드 되어 제공되고 있다. 

bool Raycast(Vector3 origin, Vector3 direction, float maxDistance = Mathf.Infinity, int layerMask = DefaultRaycastLayers, QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal);
bool Raycast(Vector3 origin, Vector3 direction, out RaycastHit hitInfo, float maxDistance, int layerMask, QueryTriggerInteraction queryTriggerInteraction);
bool Raycast(Ray ray, float maxDistance = Mathf.Infinity, int layerMask = DefaultRaycastLayers, QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal);
bool Raycast(Ray ray, out RaycastHit hitInfo, float maxDistance = Mathf.Infinity, int layerMask = DefaultRaycastLayers, QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal);

파라메터가 많아 복잡해 보이지만 디폴트 파라메터들을 제외하고 보면 결국 Raycast 함수의 핵심은 아래 세가지 정도로 요약 된다.

  • Ray 변수 : 직선의 시작점(origin)과 방향(direction)을 가지고 있는 구조체다.
  • RaycastHit 변수 :  객체와 Ray의 충돌에 대한 결과 정보를 저장하는 구조체
  • Raycast 함수 

Ray 구조체 사용법

Ray는 직선의 시작점(origin)과 방향(direction)을 가지고 있는 단순한 구조체다.

시작점(origin)은 Vector3 타입의 월드 포지션이며 방향(direction)은 직선의 방향을 나타낼 Vector3 타입의 법선 벡터다. 

Unity에서 Ray를 생성성할 수 있는 방법은 여러가지가 있다. 먼저 new를 이용해 직접 생성하는 방법이다.

// Creates a Ray from this object, moving forward
Ray ray = new Ray(transform.position, transform.forward);

카메라 뷰포트 중앙에서 시작하는 Ray와 같은 경우 헬퍼 함수를 이용해 아래와 같이 Ray를 자동으로 생성 할 수 있다.

// Creates a Ray from the center of the viewport
// 아래에서 0.5f 값은 뷰포트의 중간값을 나타낸다.
Ray ray = Camera.main.ViewportPointToRay(new Vector3 (0.5f, 0.5f, 0));

스크린의 마우스 위치로 부터 Ray를 만들어 낼수도 있다.

// Creates a Ray from the mouse position
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);

이런 헬퍼 함수들을 사용하여 월드의 특정 지점에서 부터 쉽게 Ray를 만들수 있다.

여기서 주의해야 할 부분은 Ray는 사용할 때 마다 업데이트 되어야만 한다는 것이다. 예를 들어 Ray의 시작점과 방향이 매 프레임마다 달라지는 경우 Ray도 매 프레임 마다 갱신되어야 한다.

Ray ray;
void Update() {
    ray = transform.position, transform.forward;
}

이렇게 Ray가 시작되는 위치와 방향을 결정했으면 Ray로 부터 얻은 데이터를 RaycastHit 변수에 저장한다.

RaycastHit 구조체 사용법

RaycastHit은 객체와 Ray의 충돌에 대한 결과 정보를 저장하는 구조체다. Raycast 함수의 out 파라메터로 사용되며 월드에서 레이캐스팅 히트가 발생한 위치, Ray가 충돌한 물체, Ray의 원점에서 얼마나 떨어져있는지 등의 정보를 저장하여 돌려준다.

Variables

barycentricCoordinate 충돌한 triangle의 무게중심 좌표
collider 충돌한 collider
distance Ray의 origin으로부터 충돌 지점까지의 거리
lightmapCoord 충돌 지점의 uv lightmap 좌표
normal Ray가 충돌한 surface의 normal
point Ray가 충돌한 Collider의 충돌 지점 (world 좌표 사용)
rigidbody 충돌한 collider의 rigidbody (rigidbody가 없는 경우 null 반환)
textureCoord 충돌 위치에서의 uv texture 좌표
textureCoord2 충돌 위치에서의 2차 uv texture 좌표
transform 충돌한 Transform의 Rigidbody 또는 Collider
triangleIndex 충돌한 triangle의 index

RaycastHit를 사용하기 위해선 다음과 같이 선언한다.

// Container for hit data
RaycastHit hitData;

그리고 Raycast 함수를 통해 씬에 Ray를 발사하면 캐스팅 결과에 따라 충돌에 대한 정보를 RaycastHit 변수에 저장한다. 여러분은 RaycastHit에 저장된 정보들을 아래와 같이 접근할 수 있다.

먼저 RaycastHit.point를 이용하여 월드에서 레이캐스팅이 감지된 위치를 얻을 수 있다.

Vector3 hitPosition = hitData.point;

또는 RaycastHit.distance를 사용하여 Ray의 원점에서 충돌 지점까지의 거리를 구할수 있다.

float hitDistance = hitData.distance;

Tag와 같은 히트 된 대상 객체의 Collider 세부 정보를 얻을수도 있다.

// Reads the Collider tag
string tag = hitData.collider.tag;

RaycastHit.transform을 사용하여 충돌 객체의 Transform에 대한 참조를 얻을 수도 있다.

// Gets a Game Object reference from its Transform
GameObject hitObject = hitData.transform.gameObject;

Ray와 RaycastHit 변수는 Ray가 어디로 발사되고, 그에 따른 충돌 정보가 어떻게 저장 될지를 정의하지만 이 두 가지로는 아무것도 할 수 없다. 그래서 실제 씬에서 Ray를 발사하고 충돌이 있는지 확인하기 위해서는 Raycast 함수를 사용해야 한다. Raycast 함수를 사용하는 방법은 다음과 같다.

Raycast 함수 사용법

Unity의 Raycast 함수를 사용하면 Ray가 씬의 다른 객체와 충돌하는지 여부를 알 수 있으며 충돌할 경우 충돌 정보를 RaycastHit 변수에 저장할 수 있다.

여러 버전의 Raycast함수가 있지만, Raycast를 사용하는 가장 일반적인 방법 중 하나는 Ray의 객체에 대한 히트여부에 따라 true 또는 false를 리턴하고, out 파라메터로 RaycastHit를 리턴하는 버전을 사용하는 것이다.

// public static bool Raycast(Ray ray, out RaycastHit hitInfo);
void FireRay(){
    Ray ray = new Ray(transform.position, transform.forward);
    RaycastHit hitData;
    Physics.Raycast(ray, out hitData);
}

위와 같이 하면 생성된 Ray가 씬으로 발사되고 Ray에 충돌한 어떤 것이든 그것에 관한 충돌 정보가 RaycastHit 변수에 저장된다.

앞에서 Physics.Raycast 함수의 리턴 타입은 bool이라고 했다. Ray에 어떠한 오브젝트라도 걸리면 true를 리턴한다. 이는 if 문을 이용하여 raycasting이 성공했을때 그에 대한 처리를 추가 할 수 있다는 뜻이다.

 
void Update() {
    Ray ray = new Ray(transform.position, transform.forward);
    RaycastHit hitData;
    if (Physics.Raycast(ray, out hitData)){
        // The Ray hit something!
    }
}

위와 같은 방법으로 Ray가 실제로 무엇인가에 충돌 했을 때만 if 문 내의 코드가 실행되도록 할 수 있다. 이는 RaycastHit 변수에 실제 충돌 정보가 저장 되었을 때만 RaycastHit을 사용하도록 제한 할 수 있다는 뜻이다. 

그리고 위 예제 코드에서는 간략한 소개를 위해 생략 되었지만 Raycast함수는 추가 디폴트 인자를 가지고 있다[여기]. 이 인자들을 이용해 Ray의 충돌 탐지 거리 제한, 특정 레이어 또는 트리거 콜라이더 무시하기 등의 제약사항을 추가할 수 있다. 이러한 세팅들은 어떤 오버로드 된 레이케스트 함수를 사용하느냐에 따라 달라진다.

Raycast 함수의 다양한 기능들

Unity에는 다양한 버전의 Raycast함수가 있으며 각각은 서로 약간 다른 기능을 제공하고 있다. 일부 버전은 몇가지 인자만 사용하여 간단한 기능만 수행하지만 다른 오버로드된 버전은 더 복잡한 인자들을 받아들이고 더 복잡한 작업을 한다.

예를들어 가장 기본적인 버전의 Physics.Raycast는 인자로 Ray 변수 하나만 받는다.

if (Physics.Raycast(ray)) { 
    // The Ray hit something
}

다른 오버로드 된 버전의 Physics.Raycast는 Ray, RaycastHit, MaxDistance, LayerMask(특정 레이어가 포함되거나 제외 되는 것을 지정) 및 TriggerCollider를 사용할 수 있는지 여부를 결정하는 QueryTriggerInteraction을 설정할 수 있다.

public LayerMask layerMask;
void Update() {
    Ray ray = new Ray(transform.position, transform.forward);
    RaycastHit hitData;

    if (Physics.Raycast(ray, out hitData, 10, layerMask, QueryTriggerInteraction.Ignore))
    {
        // The Ray hit something less than 10 Units away,
        // It was on the a certain Layer
        // But it wasn't a Trigger Collider
    }
}

이제 부터 Raycast 함수들이 제공하는 기능들에 대해 살펴 보도록 하자.

최대거리를 지정하여 Raycast 범위 제한

대부분의 오버로드 된 Physics.Raycast에서는 아래와 같이 레이캐스팅 최대 거리를 제한 할 수 있다.

Ray ray = new Ray(transform.position, transform.forward);
if (Physics.Raycast(ray, 10)) { 
    // Hit Something closer than 10 units away
}

최대 거리를 제한하므로써 최대 사거리가 있는 발사 무기의 명중 판정이라던지, 단순히 씬 전체를 무한히 가로질러 발생할 수 있는 다양한 문제를 예방할 수 있다.

하지만 거리 제한만으로 충분 할까? 아니다. 제한된 거리 내에서도 다양한 충돌 객체가 감지될 수 있다. 어떤 객체가 충돌에 감지 되어야 하고 그렇지 않은지를 결정하는 것은 전적으로 여러분에게 달려 있다. 이제 부터 알아 볼 것은 충돌이감지 되었을 때 어떻게 구분하여 별도의 처리를 해줄 수 있는지를 살펴 보도록하겠다.

Raycast에 Layer Mask 사용하기

Raycast 함수의 유용한 기능 중의 하나는 레이어에 따라 충돌체를 필터링하는 기능이다. 이를 통해 레이캐스팅에서 무시해야하는 객체를 쉽게 구분할 수 있다. 만일 당신이 아주 커다란 씬에서 엄청나게 많은 객체들이 있고 각각의 객체들이 서로 다양한 타입을 가지고 있다고 상상해보자. 이 기능은 이럴때 당신에게 필요한 특정 몇몇 객체들에 대해서만 레이캐스팅을 진행할 수 있게 해주는 아주 유용한 도구다.

예를 들어 지금 'world'라는 레이어가 있고 해당 레이어의 객체들만 레이캐스팅을 이용해 감지하려고 한다고 가정해보자. 당신이 가장 먼저 해야할 일은 퍼블릭 LayerMask 변수를 생성하는 것이다.

public class CameraRay : MonoBehaviour {
    public LayerMask worldLayer;
    // ...
}

이렇게 public으로 선언된 LayerMask 변수는 인스펙터에서 셋팅이 가능하다.

이제 스크립트에서 아래와 같이 Raycast 함수에 LayerMask 변수를 넘겨 주기만 하면 된다.

public class CameraRay : MonoBehaviour {
    public LayerMask worldLayer;
    void FireLaser() {
        Ray ray = Camera.main.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0));
        if (Physics.Raycast(ray, 10, worldLayer)) {
            Debug.Log("You hit a wall, good job!");
        }
    }
}

이렇게 하면 오직 "world" 레이어에 속한 객체들에 대해서만 레이캐스팅 검사를 진행하게 된다.

LayerMask를 사용할 때 레이어 번호를 직접 입력하는 방법

public 변수를 이용해 LayerMask를 선언하고 인스펙터에서 레이어를 지정하는 방법은 분명히 간단하면서도 쉬운 방법이지만 우리는 때로 스크립트에서 레이어 마스크를 동적으로 지정해야할 필요가 있을 때도 있다.

public static bool Raycast(Vector3 origin, Vector3 direction, float maxDistance = Mathf.Infinity, int layerMask = DefaultRaycastLayers, QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.UseGlobal);

레이어 마스크를 인자로 받는 Raycast 함수를 살펴 보면 int 타입을 요구하기 때문에 혹시 여러분 중에서 인스펙터의 레이어 번호를 인자로 넘기면 될것이라고 생각하는 사람이 있을 수도 있다. 아래 그림을 예로 들어 설명하면 'Server' 레이어를 선택하기 위해 9를 넘기면 될것이라 생각할 수 있다. 결론 부터 말하자면 그렇게하면 안된다.

유니티에서는 총 32개의 레이어를 지원하며 각 레이어를 구분하기 위해 32bit 비트 마스크를 사용한다. 레이어는 0부터 시작하며 31이 마지막 레이어 번호다.

9번 레이어를 선택하고 싶다면, layerMask인자로 9를 넘겨주는 것이아니라 9번 레이어는 오른쪽에서 부터 0을 포함해 10번째 이므로 아래와 같은 비트 마스크를 만들어야 한다.

그럼 정수를 이용해 유니티가 사용하는 이진값을 만들기 위해서는 어떻게 해야 하는가? 이진수는 오른쪽에서 왼쪽으로 계산되며 한칸씩 왼쪽으로 이동할때 마다 2배씩 증가한다. 예를 들어 숫자 8은 이진수로 1000이다. 

반면 9는 아래와 같이 마스킹 된다.

만일 여러분이 9번 레이어를 선택하기 위해 9를 넘기게 되면 결과적으로 위 그림과 같이 마스킹 되어 0번, 3번 레이어가 선택 되게 된다. 여러분이 9번 레이어를 선택하기 위해서는 9가 아닌 512를 넘겨 주어야 한다.

if (Physics.Raycast(ray, 10, 512)) {
    // Layer 9 was hit!
}

만일 9번과 4번 레이어를 동시에 선택하고 싶다면 아래와 같이 528을 넘겨야 한다.

if (Physics.Raycast(ray, 10, 528)) {
    // Layer 9 or 4 was hit!
}

정수를 LayerMask 값으로 변환하는 방법

앞에서 우리는 Unity는 레이어를 지정하기 위해 32bit 비트 마스크를 사용하고 있고, 레이어를 지정하기 위해서는 각 레이어 순서의 플래그가 켜져 있는 이진 값을 넘겨 줘야함을 배웠다. 하지만 앞의 예제는 우리가 이해하기에 직관적이지 못했다. 지금 부터는 쉬프트 연산자(<<)를 이용해 보다 쉽게 레이어 마스크 값을 구하는 방법에 대해 살펴 보도록 하겠다.

쉬프트 연산을 이용하는 것은 매우 간단하다. 위의 예제에서 처럼 9번 레이어를 선택하기 위해서는 0번째 레이어 마스크를 켜고 쉬프트 연산자(<<)를 이용해 왼쪽으로 9번 이동시켜 주면 된다.

if (Physics.Raycast(ray, 10, 1<<9)) {
    // Layer 9 was hit!
}

위와 같은 방식으로 단일 레이어에 대한 충돌을 감지할 수 있다. 그럼 특정 한 레이어만을 제외한 다른 모든 레이어에서 충돌을 감지하고 싶다면 어떻게 해야 할까?

하나를 제외한 모든 레이어에서 Raycast 감지

LayerMask를 사용하여 특정 레이어의 충돌을 감지할 때와 마찬가지로 LayerMask 값을 반전(invert)하여 지정된 레이어를 제외한 모든 레이어에 대해 충돌을 감지할 수 있다.

이건은 비트 연산자 중 NOT(~)연산자를 이용하면 된다. 비트 NOT 연산은 물결표(~)를 사용하고 모든 비트를 뒤집어 반전 시킨다. 예를 들어 9번 레이어를 제외한 모든 레이어에대해 감지하고 싶다면 다음과 같이 하면 된다.

Ray ray = new Ray(transform.position, transform.forward);
if (Physics.Raycast(ray, 10, ~(1<<9))) {
    Debug.Log("something else was hit");
}

비트 연산자를 사용하여 코드에 레이어 마스크를 직접 추가하는 경우 값을 반전하기 전에 비트 연산이 먼저 수행 되도록 괄호 안에 배치하는 것이 좋다.

만일 LayerMask 변수를 따로 가지고 있다면 아래와 같이 간단하게 처리할 수도 있다.

public LayerMask worldLayer; // 레이어 마스크 변수

void FireLaser() {
    Ray ray = new Ray(transform.position, transform.forward);

    if (Physics.Raycast(ray, 10, ~worldLayer)) {
        // Something other than the world was hit!
    }
}

레이어 이름으로 레이어 번호를 알아 오는법

앞의 예제에서는 레이어를 지정하기 위해 레이어 번호를 직접 입력했다. 하지만 여러 가지 개발적 이슈로 인해 레이어의 번호가 변경 될 수도 있다. 이 때 마다 코드를 검색하여 레이어 번호를 사용하는 부분을 일일이 수정한다는 것은 비효율적인 일이다. Unity에서는 레이어의 문자열 이름으로 부터 레이어 번호를 얻을 수 있는 LayerMask.NameToLayer 헬퍼 함수를 제공하고 있다.

예를 들어 5번 레이어의 이름이 "UI"라고 가정한다면 아래와 같은 코드는 정수 5를 리턴한다.

int layerNum = LayerMask.NameToLayer("UI");
Debug.Log(layerNum); // 5

주의 할 점은, NameToLayer 함수에서 리턴 되는 값을 바로 Raycast에 사용하면 안된다는 것이다. 앞에서 이미 다루었듯이 레이어 마스크는 이진 데이터를 파라메터로 받는다. 쉬프트 연산을 통해 해당 위치의 비트를 켜주어야 한다.

Ray ray = new Ray(transform.position, transform.forward);
int layerNum = LayerMask.NameToLayer("UI");

if (Physics.Raycast(ray, 10, 1<<layerNum)) {
​​​​Debug.Log("something else was hit");
}

레이어 번호로 레이어 이름을 알아 오는법

앞에서 레이어 이름으로 레이어 번호을 알아 왔듯이 레이어 번호를 이용해 레이어의 이름을 알아 낼 수도 있다.

int layerNum = LayerMask.NameToLayer("UI");

string layerName = LayerMask.LayerToName(layerNum);
Debug.Log(layerName); // UI

Raycast를 사용할 때 trigger collider를 무시하는 법

만일 Raycast 함수가 trigger collider에 대해서 동작하는 것을 원치 않는 경우 해당 객체를 별도의 레이어에 배치하는 방법도 있겠지만 그리 좋은 방법은 아니다. 이번 섹션에서는 레이어에서 트리거 콜라이더를 무시하는 방법에 대해 살펴 보도록하겠다.

기본적으로 raycasting은 트리거 콜라이더를 감지한다. 레이캐스트가 트리거 콜라이더에 충돌하면 다른 콜라이더와 동일한 방식으로 동작한다. 하지만 프로젝트 설정을 통해 전역적으로 또는 Raycast별로 동작을 변경할 수 있다.

모든 Raycast Trigger 충돌을 비활성화 하는 법

모든 Raycast 트리거 충돌을 비활성화 하는 가장 간단한 방법은 프로젝트 셋팅에서 해당 옵션을 끄는 것이다. Project Setting를 열고 Physics 메뉴를 선택후 Queries Hit Trigger의 체크를 해제한다.

이제 기본적으로 Raycast는 모든 트리거 충돌을 무시하게 된다. 그리고 이 옵션을 끈 상태에서도 Raycast 함수의 QueryTriggerInteraction 파라메터를 이용해 전역 설정을 덮어 쓸 수 있다.

void FireLaser() {
    Ray ray = Camera.main.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0));
    if (Physics.Raycast(ray, 10, worldLayer, QueryTriggerInteraction.Ignore)) {
        // Whatever you hit, it wasn't a trigger
    }
}

QueryTriggerInteraction 파라메터는 아래 세 가지 중 하나의 값을 가질 수 있다.

  • Ignore - 트리거 콜라이더의 충돌을 무시한다.
  • Collider - 트리거 콜라이더의 충돌을 허용한다.
  • UseGlobal - Physics 옵션에 정의된 기본 값을 따른다
※ Physics2D.Raycast 의 경우에는 위와 같은 enum 값이 아닌 Physics2D.queriesHitTrigger를 이용하여 true, false를 사용한다고 한다.
https://stackoverflow.com/questions/44402021/how-to-make-raycast-ignore-trigger-colliders

Raycast를 이용하여 여러 물체를 맞추는 법

Raycast 함수는 단일 객체에 충돌이 발생하면 true를 리턴하고 멈춘다. 하지만 때때로 레이저가 여러 물체를 관통하는 것과 같이 동일 Ray를 사용하여 여러 객체에 대한 충돌을 검사해야 하는 때가 있다. 이런 경우 단일 객체에 대한 레이캐스팅을 진행하는 Raycast 함수 대신 RaycastAll을 사용할 수 있다.

하나의 Ray로 여러 객체에 대한 충돌을 검사하고 싶을 때는 RaycastAll을 사용한다.

RaycastAll 함수 사용법

RaycastAll 함수는 기본적으로 Raycast함수와 매우 비슷하게 동작한다. 단 Raycast 함수에서 단 하나의 객체에 대한 충돌 정보만 반환하는 대신 RaycastHit 구조체 배열을 이용해 여러 개체에 대한 충돌 정보들을 반환한다. 

public RaycastHit[] hits;

void Update() {
    Ray ray = new Ray(transform.position, transform.forward);
    hits = Physics.RaycastAll(ray);
}

RaycastAll은 단일 Ray를 사용하여 총돌한 여러 객체에 대한 정보를 얻는데 사용 된다. 예를 들어 아래와 같이 Ray가 충돌한 객체들의 개수를 알아 낼 수 있다.

int numObjectsHit = hits.Length;

아니면 Ray의 경로에 있던 모든 객체들을 파괴하는데 사용 될 수도 있다.

public class CameraRay : MonoBehaviour {
    public RaycastHit[] hits;

    void Update() {
        if(Input.GetMouseButtonDown(0)) {
            FireLaser();
        }
    }

    void FireLaser() {
        Ray ray = new Ray(transform.position, transform.forward);
        hits = Physics.RaycastAll(ray);

        foreach(RaycastHit obj in hits) {
            Destroy(obj.transform.gameObject);
        }
    }
}

 

RaycastAll의 문제

앞에서와 같이 RaycastAll은 Ray에 충돌하는 모든 객체에 대한 정보를 얻어 오는데 유용하게 사용 될 수 있다. 단, RaycastAll은 여러 충돌체를 감지 할 수는 잇지만 정의되지 않은 순서로 검색한다. 우리가 직관적으로 생각하기에 RaycastAll에 의해 리턴 되는 정보는 물체를 통과한 레이저로써 시작점과 가까이 있는 순서대로 배열에 들어갈것 같지만 실제 결과값은 예측 할 수 없는 순서로 저장된다. 

결과값에 대해 동시에 처리를 한다면 이렇게 순서가 뒤섞이는것이 문제가 되진 않지만 순서가 중요하다면 아래와 같이 거리에 따라 배열을 정렬하는 방법도 있다.

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

void FireLaser() {
    RaycastHit[] hits;
    Ray ray = new Ray(transform.position, transform.forward);
    hits = Physics.RaycastAll(ray);

    // Sorts the Raycast results by distance
    Array.Sort(hits, (RaycastHit x, RaycastHit y) => x.distance.CompareTo(y.distance));
}

RaycastAll vs RaycastNonAlloc

RaycastNonAlloc은 앞에서 살펴본 RaycastAll과 매우 유사하게 동작한다. 단, 한가지 차이점이 있다면 RaycastNonAlloc은 RaycastAll 처럼 호출 될 때 마다 내부적으로 RaycastHit 배열을 생성 후 리턴하는 방식이 아니라, 외부에서 이미 생성된 배열을 out 파라메터로 재사용 할 수 있어 가비지(garbage)의 발생을 줄인다.

RaycastNonAlloc는 충돌한 객체의 개수를 리턴하지만 그 수는 인자로 넘겨진 배열의 길이 보다는 크지 않다. 실제 반환된 충돌된 객체의 개수를 알면 리턴된 배열이 가득 차지 않았을 때 빈 요소들을 참조하는 것을 방지할 수 있다.

RaycastHit[] results = new RaycastHit[10];

void Update(){
    if (Input.GetMouseButtonDown(0)) {
        FireLaser();
    }
}

void FireLaser(){
    Ray ray = Camera.main.ViewportPointToRay(new Vector3(0.5f, 0.5f, 0));
    int hits = Physics.RaycastNonAlloc(ray, results);

    for(int i=0; i < hits; i++){
        Destroy(results[i].transform.gameObject);
    }
}

일반적으로 RaycastNonAlloc은 RaycastAll 보다 효율적인 버전이다. 하지만 RaycastAll과 마찬가지로 RaycastNonAlloc 역시 정의되지 않은 순서로 충돌 정보 배열을 리턴한다. 이게 왜 문제가 되냐면 RaycastNonAlloc으로 부터 리턴 되는 충돌 객체에 대한 정보들은 지정된 배열의 길리 제한으로 인해 모두 리턴 되지 않을 수 있다. 이 배열은 정의 되지 않은 순서로 저장되기 때문에 리턴된 충돌 정보들이 시작점으로 부터 가까운 객체들이라는 보장이 없다.

예를 들어 최대 3명의 적을 관통하는 무기를 만들려는 경우, 결과를 받아올 RaycastHit 배열의 크기가 3인 경우, RaycastNonAlloc을 사용하면 3개의 결과가 반환 되긴하지만 이 결과를 정렬하더라도 가장 가까운 3개가 될것이라는 보장을 하지 못한다.

 

RaycastNonAlloc이 얼필 보면 성능을 향상 시킬 수 있는 좋은 방법 처럼 보이지만 위와 같은 문제가 있다. 따라서 이를 효과적으로 사용하려면 LayerMask와 같은 기능들을 사용하여 raycasting 결과로 리턴되는 개수가 한정적일 때 최대 길이의 배열을 사용하여 반복적인 배열을 재할당 없이 사용하는 것이 가장 적합한 방법이다.

https://kukuta.tistory.com/391

 

[Unity] Physics.Raycast 완벽 가이드

들어가며 Unity의 Physics.Raycast는 직선을 씬에 투영하여 대상에 적중되면 true를 리턴하는 물리 함수다. Raycast 함수는 캐스팅 성공 실패에 따른 결과만 리턴하는 간단한 형태에서 부터 대상과 Ray의

kukuta.tistory.com

 

'유니티스크립팅 > 유니티매뉴얼' 카테고리의 다른 글

라이트매핑  (0) 2023.03.19
네비게이션과 경로탐색  (0) 2023.03.19
카메라 - 유니티  (0) 2023.03.03
마우스좌표를 월드좌표로 - 2D  (0) 2023.03.02
[유니티매뉴얼] Unity의 회전 및 방향  (0) 2023.02.28
Unity가 편한 개발 엔진이라고 아무런 기본 지식 없이 마냥 닥치는대로 하려다보니 이래저래 어려움이 많다. 이 포스팅에서는 Unity를 사용하면서 개인적으로 계속 헷깔렸던 부분들을 정리 한다.

Transform.SetParent 의 worldPositionStays 인자

  • worldPositionStays 가 true일 경우 :
    SetParent시 자식이 되는 오브젝트의 월드 좌표는 변경되지 않는다(화면상으로 보기엔 그냥 그대로 있는것 같다는 말).
    하지만 부모가 변경 되었으므로 로컬 좌표가 변경된다(이것을 이해하기 위해서는 월드 좌표와 로컬 좌표의 개념을 알아야 한다. 하지만 나는 이미 알고 있으므로 이 포스트에서는 다루지 않는다).

    예를 들어 부모가 없는 두 오브젝트(부모가 없다는 것은 월드 포지션과 로컬 포지션이 같다는 의미) A가 (0, 1)에, 오브젝트 B가 (1, 0)에 있는 경우, SetParent 함수 호출시 worldPositionStays를 true로 하면 자식이 되는 오브젝트 B의 월드 포지션은 여전히 (1, 0)이고 부모와의 관계를 나타내는 로컬 포지션은 (1, -1)로 변경 된다(유니티 인스펙터에는 로컬 포지션이 표시된다).
  • worldPositionStatys가 false일 경우 :
    자식이 되는 오브젝트의 로컬 좌표 값을 새로운 부모와 그대로 유지한다. 오브젝트 B가  오브젝트 A의 자식이 되는 경우 B의 로컬 포지션(1, 0)은 그대로 유지되며 대신 월드 포지션이 변경 된다.

LLVM(Low Level Vritual Machine)

프로그램을 컴파일 타임, 링크 타임, 런타임 상황에서 프로그램의 작성 언어에 상관 없이 최적화를 쉽게 구현할 수 있도록 구성. LLVM으로 언어에 가상 기계를 생성, 가상 기계가 언어에 독립적인 최적화를 실행한다.

IL2CPP(Intermediate Language to CPP)

유니티 2차원 좌표계

Resources

GameObject

Transform

  • http://docs.unity3d.com/ScriptReference/Transform.html
  • scene 안에 있는 모든 객체들이 가지고 있는 위치, 스케일, 회전 정보
  • transform은 부모 transform을 가질 수 있다
  • localPosition은 부모의 transform에 상대적 위치 정보
  • GetComponentXXX<T> 함수들을 잘 이용하면 객체들을 찾기가 쉬워진다.

RectTransform

  • http://docs.unity3d.com/ScriptReference/RectTransform.html
  • 주로 UI를 위해 사용 됨(아마도..)
  • 위치, 사이즈, 앵커, 피봇 관련 정보들을 담고 있음
  • sizeDelta : RectTransform의 앵커 기준 상대적 사이즈. 만일 앵커가 붙어 있다면 sizeDelta는 size와 동일하다. 만일 앵커가 부모의 각 네 코너에 맞춰져 있다면 sizeDelta는 부모의 사각형 대비 얼마나 크거나 작은지를 나타낸다.
  • rect : eg. rectTransform.rect.height 으로 RectTransform 객체의 높이를 가져 올 수 있다

Gizmo

애니메이션 종료 여부 알아 내기

SVN 연동시 무시 되어도 될 디렉토리와 파일 리스트

안드로이드에서 파일 read/write 하기

saving and loading player game data in unity 

4.6 UI Text rect does not expand automatically

ScreenToWorldPoint

Touch Input Position

BoxCollider

  • BoxCollider 를 설정하고 IsTrigger 옵션을 선택하면 OnCollisionXXX 대신 OnTriggerXXX 함수가 호출 된다. IsTrigger 셋팅이 되어있다면 오브젝트간 물리적 상호 작용은 없고 서로 겹칠때 알림만 온다.

Dictionary 시리얼라이즈

content size fitter

부모 객체의 rect를 자식 객체들에 맞춰서 변경 시켜주는 역할. 하지만 자식에 Vertical Layout 혹은 Horizon Layout 같은 Layout이 붙어야 한다. 텍스트 같은 경우는 아마도 예외인것 같다.

JsonUtility

스크립트 직렬화

Application.OpenURL

AudioListener.volumn

마우스 클릭으로 객체 선택하기

한때 카메라를 공부한적이 있어 유니티의 RayCast를 공부하다 보니 다시 한번 빛의 발사와 반사를 생각해 봤다. 인터넷에 자료가 별로없어 직접 만들어봤는데 학문적인 정도는 아니니 참조만 하시기 바란다. 틀릴수도 있음 ㅎㅎ

우리가 물체가 보이는 이유는 광원이 있어서 이다 물론 일부 자체 발광하는 것도 있지만 .

정확히 말해 우리는 물체를 보는것이 아니라 반사되는 빛을 눈을 통해 보고 뇌로 느낄 뿐이다.

다음은 빛이 물체에 반사되는 경로를 보여준다.

1. 전구와 같은 광원은 사방으로 퍼진다.

2. 이중 물체에 반사되어진 빛은 다시 넓게 퍼진다.

3. 만일 앞에 스크린이나 종이가 있다면 이 퍼진 빛들이 충돌하게 되는데 사방에서 온 빛들이 섞여 구분할 수 없다.

4. 실제 우리가 아무리 종이를 쳐다보고 있어도 거울처럼 아무것도 반사되지 않는 점을 생각해보자.

어릴때 핀홀카메라라고 실험해보신 적이 있을것이다. 물체와 종이사이에 작은 구멍이 뚫린 종이를 놓자.

1. 물체에 반사돼 넓게 퍼져나가던 빛중 구멍을 통과하는 일부 빛만 통과되어 건너편 종이에 맺히게 된다. 

2. 물체에 모든 점에 반사되 빛은 건너편 종이에서 섞이지 않게되어 우리가 하나하나의 점을 구분할수 있게되거 전체적으로 상으로 보인다.

3. 종이에 맺힌 상은 상하좌우가 반대로 되게 된다. 집에서 실험해면 역시 허옇게 아무것도 안보인다 왜냐하면 주변에 빛이 너무 밝기 때문이다. 그래서 검은 종이상자로 주변빛을 차단하면 성공하는거다.

4. 방에 창문을 검정색으로 가리고 쪼그만 구멍을 뚫으면 거짓말 처럼 벽에 밖의 풍경이 찍히게 된다.

핀홀카메라의 개선 

핀홀카메라의 단점은 빛이 한구멍만 통과하다보니 어둡다는 거다. 사람의 눈은 렌즈가 있어 빛을 모아 밝은거다. 이걸 흉내내어 핀홀 구멍을 크게해보자 그러면 문제가 물체의 한점에서 반사된 빛민 건너편 스크린에 맺히는게 아니라 널어진 홀만큼의 사이즈 만큼의 반사된빛이 섞이게 되면서 흐릿하게 된다. ㅠㅠ

렌즈로 초점을 모으자

사람의 눈을 잘 보자. 생물시간에 배우셨겠지만. 렌즈가 들어 있다. 렌즈는 빛을 모아준다. 따라서 구멍을 통과한 빛을 렌즈를 통해 한점으로 스크린위에 모이므로 전체적인 이미지는 또렸하게 된다. 

 

그러나 이 렌즈 시스템도 문제가 있다 왜냐하면 렌즈의 초점거리는 정해져 있기 때문에 일정한 거리의 점들만 잘 보이게 되고 더 멀거나 가까운 물체는 다시 흐릿하게 된다.

인간의 눈의 렌즈는 말랑말랑한 수정체로 실시간으로 초점거리를 변경해 다양한 거리의 물체의 초점을 잡을수 있게 만들었다.

우리가 낮에 사물을 보면 단일평면이 아니 꽤 넓은 범위의 물체를 깨끗히 볼수 있어 위의 설명이 거짓말 같지만 낮에는 빛이 밝아 눈의 조리개가 작아지면서 빛이 통과하는 구멍이 작아져 위의 핀홀카메라와 같이 한점만 통과해서 그렇다. 그걸  피사체 심도가 싶다는 표현을 쓴다. 여러분이 좋와하는 뽀샤시는 조리개를 열어 특정한 범위에만 초점을 맺히게히 배경을 흐리는 효과도 이해하길수 있을거다.

 

종이대신 촬상소자를 놔보자

카메라는 촬상소자라는게 있어 빛을 2차원의 전기신호로 바꿔준다.

아래 오른쪽을 보면 4x3의 센서들이 배열로 배열되어 있다. 이걸 전기신호로 바꾸면 해상도가 수평x수직: 4*3인거다. 아마 촬송소자에 비친 이미지는 해상도가 너무 낮아 알아보기 힘들거다. 

하여간 우리 카메라는 지금 4x3의 센서들이고 잘생각해보면 광원에서 출발해서 센서에 도착한 광선의 수는 4*3=12개이다.

우리는 촬상소자 12개에 도착하는 광량을 12번의 계산으로 시뮬레이션할수 있다..해상도만큼의 계산만 하면되는거다.

반사각이 -90~90도 사이의 광선만  CCD에 도달할 것이고 대략 광원의 밝기 x 물체의 질감및 반사각에 따라는 반사율이 될것이다. 

유니티의 게임뷰

유니티는 월드좌표에 조명과 게임오브젝트, 카메라가 배치되어 있다.  위의 그림과 다를게 없다. 해상도가 좀 높을 뿐이다.

따라서 해상도가 1920x1080이라면 약200만개의 광선이 필요하다. 실제적으로는 RGB 3원색 이므로 600만개일수도 있다.  아직 유니티의 내부 렌더링 구조는 모르지만 CCD까지 시뮬레이션 할 필요는 없을것 같고 필홀부분이 카메라의 Origin좌표가 되어 여기를 기점으로 입사광선의 반사율을 계산하면 월드좌표를 게임뷰로 변환할수 있을것 같다.

여태까지 서론이 길었던건 바로 다음 기능을 생각해보기 위해서다.

  • Ray 변수 : 카메라 원점에서 에서부터 타겟지점까지의 벡터, 스크린해상도에 해당하는 광선중 한개
  • RaycastHit 변수  : 충돌 정보가 담기는 변수
  • Raycast 함수 : 레이저를 Ray정보를 이용해서 쏴준다. 

유니티에서는 Origine에서 레이저를 쏴서 충돌검사를 할수 있는데 위의 카메라의 입사하는 광선이 꺼꾸로  카메라 원점에서 부터 발사된다고 생각하면 간단하다.

카메라 원점에서부터 해상도만큼의 광선이 존재할수 있고. 마우스좌표는 이 광선들 중이 하나를 지나가고 이 광선을 추적하면 월드좌표내의 오브젝트의 충돌을 검출할 수 있다.

내 상상이 맞는지는 모르겠지만. Raycast를 공부하면서 느낀점을 정리 해봤다.

카메라 원리

 

Perspective와 Orthographic의 차이

 

 Camera 오브젝트의 Inspector 창에서 Projection의 두 가지 옵션이 있습니다. Percpective Orthographic이 있는데 Percpective는 사물에 대해 원근감과 공간감을 잘 표현하여서 보여주고, Orthographic은 사물에 대해서 원근감과 공간감 없이 표현을 해줍니다. 보통 2D나 2.5D를 제작할 시에는 Orthographic을 사용하여 제작합니다.

Perspective vs Orhographic

뷰 절두체 이해

절두체 는 피라미드 같은 모양의 윗부분을 밑면에 병렬로 잘라낸 입체 형상을 가리킵니다. 이는 원근(Perspective) 카메라에 의해 보여지고 렌더링되는 영역의 형상입니다.

원거리 절단면과 근거리 절단면은 모두 카메라의 XY 평면에 평행하게 위치하고 있으며, 만일 어떤 것이 근거리 절단면보다 카메라에 근접하거나 원거리 절단면보다 카메라에 멀리 떨어져있는 경우에는 렌더링되지 않습니다.

 

카메라에서 일정 거리 떨어진 절두체의 크기

카메라에서 일정 거리 떨어진 뷰 절두체의 교차 영역은 가시 영역을 구성하는 월드 공간에서 사각형으로 정의됩니다. 떨어진 거리를 알고 있으면 사각형의 크기를 계산할 수 있고, 사각형의 크기를 알고 있으면 떨어진 거리를 계산할 수 있기 때문에, 이를 유용하게 활용할 수 있습니다. 예를 들어, 움직이는 카메라가 항상 플레이어와 같은 특정 오브젝트를 샷 안에 계속 온전하게 담아내야 할 때, 오브젝트가 잘리지 않도록 카메라의 적정 거리를 유지할 수 있습니다.

일정 거리만큼 떨어진 절두체의 높이(두 값 모두 월드 단위)는 다음 공식을 통해 구할 수 있습니다.

 var frustumHeight = 2.0f * distance * Mathf.Tan(camera.fieldOfView * 0.5f * Mathf.Deg2Rad);

또한 이 과정을 반대로 하면 특정 절두체 높이일 때의 거리를 계산할 수 있습니다.

 var distance = frustumHeight * 0.5f / Mathf.Tan(camera.fieldOfView * 0.5f * Mathf.Deg2Rad);

거리와 높이를 모두 알고 있을 때에는 FOV 각도를 계산할 수도 있습니다.

 var camera.fieldOfView = 2.0f * Mathf.Atan(frustumHeight * 0.5f / distance) * Mathf.Rad2Deg;

각 계산식의 결과를 얻으려면 절두체의 높이를 알아야 하며, 절두체의 높이는 절두체의 너비를 이용하여 쉽게 구할 수 있습니다(절두체의 높이를 이용하여 너비를 계산할 수도 있음).

var frustumWidth = frustumHeight * camera.aspect;
var frustumHeight = frustumWidth / camera.aspect;

카메라에서 나오는 레이

Ray

public Ray (Vector3 origin, Vector3 direction);

origin에서 출발해서 direction을 따라가는 광선을 만듭니다. 

 Ray ray = new Ray(transform.position, transform.forward);

 

카메라의 뷰에서 모든 점은 월드 공간의 하나의 선에 대응됩니다. 때로는 그 선의 수학적인 표현을 사용하는 것이 편리한 경우가 있으며 Unity는 이것을 레이(Ray) 오브젝트로 제공할 수 있습니다. 레이는 항상 뷰 내의 한 점에 부합하므로 Camera 클래스는 ScreenPointToRay  ViewportPointToRay 함수를 제공합니다. 이 둘의 차이는 ScreenPointToRay가 점을 픽셀 좌표를 필요로 하는 반면 ViewportPointToRay는 0..1(뷰에서 0이 왼쪽 또는 아래쪽 1이 오른쪽 또는 위쪽) 범위의 정규화된 좌표를 요구한다는 점입니다. 이 각각의 함수는 원점과 원점으로부터 나가는 선의 방향을 나타내는 벡터로 구성된 레이의 값을 반환합니다. 레이는 카메라의 transform.position 포인트 대​​신 근접 클리핑 평면을 기점으로 하고 있습니다.

레이캐스팅(Raycasting)

카메라를 통한 레이의 가장 일반적인 사용 방법은 씬에 Raycast를 실행하는 것입니다. 레이캐스트는 가상의 “레이저 빔”을 원점에서부터 레이에 따라 씬 안의 콜라이더에 충돌할 때까지 보냅니다. 그 다음 오브젝트와 RaycastHit 오브젝트의 충돌된 점에 대한 정보를 반환합니다. 이것은 스크린상 나타난 이미지를 기반하여 오브젝트가 어디에 위치하는지 찾는 유용한 방법입니다. 예를 들어, 마우스 포지션에 있는 오브젝트는 다음 코드로 확인할 수 있습니다.

using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public Camera camera;

    void Start(){
        RaycastHit hit;  //충돌정보를 받아오는 변수
        Ray ray = camera.ScreenPointToRay(Input.mousePosition);  //레어저벡터
        
        if (Physics.Raycast(ray, out hit)) { //레이저를 쏨
            Transform objectHit = hit.transform;
            
            // Do something with the object that was hit by the raycast.
        }
    }
}

레이를 따라 카메라 이동

때로는 화면의 한 포지션에 반응하는 레이를 설정하고 카메라를 레이에 따라 이동시키는 것이 유용할 때가 있습니다. 예를 들어, 사용자가 오브젝트를 마우스로 선택하여 동일한 화면 위치를 “고정”하면서 줌인할 경우가 있습니다(이것은 카메라가 전술 지도를 볼 때 유용할 수 있습니다). 이 작업을 위한 코드는 매우 간단합니다.

using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public bool zooming;
    public float zoomSpeed;
    public Camera camera;

    void Update() {
        if (zooming) {
            Ray ray = camera.ScreenPointToRay(Input.mousePosition);
            float zoomDistance = zoomSpeed * Input.GetAxis("Vertical") * Time.deltaTime;
            camera.transform.Translate(ray.direction * zoomDistance, Space.World);
        }
    }
}

마우스를 움직여서 2D 오브젝트를 움직여 보겠다 실험을 위해 2D 프로젝트를 만든뒤 2D Sprite Circle을 하나 만들고 포지션을 리셋한다. 나중에 아시겠지만 마우스는 게임오브젝트가 투영되는 스크린의  해상도와 카메라의 Z좌표를 갖는다.

그런데 게임오브젝트는 각자의 좌표가 있고 씬뷰에 보이는 범위는 카메라에 의존한다. 따라서 마우스의 좌표를 게임오브젝트에 적용하기 위해서는 2좌표간의 차이를 알고 변환해 줘야한다.

그다음 ScreenToWorld라는 스크립트를 다음과 같이 붙여준다. 무척 직관적이다.

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

public class ScreenToWorld : MonoBehaviour
{
    public Vector2 mousePosition;

    // Update is called once per frame
    void Update()
    {
        mousePosition = Input.mousePosition;
        transform.position = mousePosition;
    }
}

실행히보면 원이 없어진다. 원인을 찾아보자.  

유니티 2D 기본스크린 화면이다. 객체포지션은 리셋하면  (0,0,0)인데 스크린 범위를 보기위해 (5,4,-10)으로 카메라 Z위치와 맞추었다.

하이라키뷰의 Circle을 클릭하고 Inspector를 보면 포지션이  각자 다르겠지만 마우스좌표는 스크린 좌표라 x:0~1980 y:0~1080,인데 이게 월드봐표 x:±8.8ㄹ y:±5f를 넘어서면 안보이게 된다. 

스크립트에 mousPosition을 Public으로 했기 때문에 마우스를 움직이면 좌표값을 확인할 수 있다.

마우스를 왼쪽아래로 잘 몰아보면 0에 가까워지면서 Circle이 보이게 된다.

문제는 Input.mousePosition과 transform.position과의 차이 때문이다. 왼쪽이 마우스좌표 오른쪽이 transform.position이다

두 시스템의 숫자차이도 문제지만 스크린은 (0,0)점이 좌하이고 월드좌표는 가운데다 따라서 offset(1920/2,1080/2)를 생각해야한다. 월드좌표는 현재 아래와 같고 만일 카메라의 위치 회전에 따라 변화할수 있다.

문제를 알았으니 다음 스크립트를 넣고 다시한번 해보자.

유니티는 모니트의 화면사이즈를 Screen.width, Screen.height로 얻을 수 있고 camera world의 높이를 Camera.main.orthographiSize로 얻을수 있어 이걸 화면비를 곱하면 폭도 얻을 수 있다.

실험을 위해 public변수를 많이 만들었다. 마우스를 움직이면서 봐주길 바란다.

 

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

public class ScreenToWorld : MonoBehaviour
{
    public Vector2 mPos;  //마우스 좌표
    public float mWidth;  //마우스 스크린 폭
    public float mHeight;  //마우스 스크록 높이
    public float wWidth;  
    public float wHeight;
    public float xscale,yscale;

    // Update is called once per frame
    private void Start() {
        mWidth = Screen.width;  //좌측에서 우측까지의 사이즈
        mHeight= Screen.height;  //아래서 위까지의 사이즈
        
        wWidth = Camera.main.orthographicSize * mWidth / mHeight;  //중앙에서 오른쪽까지 화면의 반
        wHeight = Camera.main.orthographicSize;  //중앙에서 위까지의 화면의 반
        yscale = wHeight *2 / Screen.height;
        xscale = wWidth *2 / Screen.width;
    }
    void Update()
    {
        mPos = Input.mousePosition;
        transform.position = MouseToWorld(mPos);
    }
    Vector2 MouseToWorld(Vector2 mPos) {
        return new Vector2((mPos.x - Screen.width/2) * xscale, (mPos.y- Screen.height / 2) * yscale);
    }
}

위 방법은 카메라가 월드좌표의 (0,0,0)을 바라 보고 있을 경우만 잘동작하고 그렇지 않은 경우 잘 안될것이다. 좀더 섬세한 계산이 필요할 것다. 

그런데 유니티가 이럴줄 알고  스크린좌표를 월드좌표를 마련해주었다. 잘보면 Vector2를 사용한다.

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

public class ScreenToWorld : MonoBehaviour
{
    Vector2 worldPosition;
    Vector2 mousePosition;

    // Update is called once per frame
    void Update()
    {
        Screen2World();
        transform.position = worldPosition;
    }
    void Screen2World() {
        mousePosition = Input.mousePosition;
        worldPosition = Camera.main.ScreenToWorldPoint(mousePosition);
    }
}

Vector3를 사용하면 Input.mousePosition의 리턴값의 Z좌표가 카메라의 Z좌표 -10되면서 카메라가 자기와 같은 평면의 Circle을 못보게 된다. 자기눈과 같은 평면에 걸 볼수가 없다. Vector2에서는 Z정보를 전달안하기 때문에 이런 문제가 안 일어 났던거다. 따라서 변환된 world좌표에 게임객체의z좌표를 카피해 주면 간단히 끝난다. 이외에도 방법은 여러가지 생각해보시길

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

public class ScreenToWorld : MonoBehaviour
{
    Vector3 worldPosition;
    Vector3 mousePosition;

    // Update is called once per frame
    void Update()
    {
        Screen2World();
        transform.position = worldPosition;
    }
    void Screen2World() {
        mousePosition = Input.mousePosition;
        worldPosition = Camera.main.ScreenToWorldPoint(mousePosition);
        worldPosition.z = transform.position.z; 
        Debug.Log(mousePosition+ " " + worldPosition);
    }
}

'유니티스크립팅 > 유니티매뉴얼' 카테고리의 다른 글

[유니티매뉴얼] Physics Raycast Ray RaycastHit  (0) 2023.03.04
카메라 - 유니티  (0) 2023.03.03
[유니티매뉴얼] Unity의 회전 및 방향  (0) 2023.02.28
Vector3  (0) 2023.02.25
변수와 함수  (0) 2023.02.25

+ Recent posts