TIL

[멋쟁이사자처럼 부트캠프 TIL회고] 애니메이션, 상태 EditorGUILayout,렉돌(Ragdoll), 충돌 처리, 카메라 처리

Cadi 2025. 4. 14. 18:40

오늘은 여러 날들 수업을 들으며 몰랐던 내용들만 뽑아서 간단하게 정리해 봤다. 

나온 내용들 중 정리를 따로 해야한다고 느끼는 부분들은 따로 포스팅할 예정이다. 

 

01. Lerp VS SLerp

Lerp와 SLerp 모두 값의 변화를부드럽게 만들어주는 보간(Interpolation) 함수지만, 변화의 방식에서 차이가 있다. 

 

Lerp

  • 두 값 사이를 직선으로 연결하여 변화, 시간이 지남에 따라 일정한 비율로 선형적으로 증가하거나 감소
  • 시작점에서 끝점까지의 최단 거리 (직선 경로)를 따름
  • 변화율이 일정, 처음부터 끝까지 같은 속도로 값이 변함
  • 단순한 값의 부드러운 변화나 직선 운동, 선형적 움직임에서 사용

SLerp

  • 주로 회전을 보간할 때사용되며, 두 회전 사이를 구면의 호를 따라 가장 짧은 경로로 회전
  • 3차원 공간에서의 방향 변화를 자연스럽게 만들어줌
  • 회전 속도가 처음과 끝에서 느리고, 중간에서 가장 빠름
  • 3D 공간에서의 부드러운 회전 변화, 회전 애니메이션 등에서 사용

 

 

3차원 공간에서 회전 자체를 직선으로 보간하게 된다면, 회전 경로가 구의 표면을 따라가지 않고 직선으로 가게 되어

회전 속도가 일정하지 않고 이상하게 왜곡되기 때문에 SLerp를 사용해 주어야 한다. 

* 참고 : Mathf.Pingpong과 같은 함수도 있다 ( 값이 왔다갔다 ) 

 


02. 애니메이션 이벤트

 

 

공격 애니메이션에서 이벤트들을 설정, 공격 판정이 이벤트 사이에서만 발생할 수 있도록

StartAttack, EndAttack등을 설정해 그 구간에서만 충돌이 발생하게 한다. 

 

03. 상태를 나타내는 GUILayout 그리기

 

상태가 올바르게 변하고 있는지를 확인하기 위한 GUILayout을 그리는 함수다.

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

[CustomEditor(typeof(PlayerController))]
public class PlayerControllerEditor : Editor
{
    public override void OnInspectorGUI()
    {
        // 기본 인스펙터를 그리기
        base.OnInspectorGUI();
        
        // 타겟 컴포넌트 참조 가져오기
        PlayerController playerController = (PlayerController)target;
        
        // 여백 추가
        EditorGUILayout.Space();
        EditorGUILayout.LabelField("상태 디버그 정보", EditorStyles.boldLabel);

        EditorGUILayout.BeginVertical(EditorStyles.helpBox);
        
        // 상태별 색상 지정
        switch (playerController.CurrentState)
        {
            case PlayerState.None:
                GUI.backgroundColor = new Color(1, 1, 1, 1f);
                break;
            case PlayerState.Idle:
                GUI.backgroundColor = new Color(0, 0, 1, 1f);
                break;
            case PlayerState.Move:
                GUI.backgroundColor = new Color(0, 1, 0, 1f);
                break;
            case PlayerState.Jump:
                GUI.backgroundColor = new Color(1, 0, 1, 1f);
                break;
            case PlayerState.Attack:
                GUI.backgroundColor = new Color(1, 1, 0, 1f);
                break;
            case PlayerState.Hit:
                GUI.backgroundColor = new Color(0.1f, 0.1f, 0.1f, 1f);
                break;
            case PlayerState.Dead:
                GUI.backgroundColor = new Color(1, 0, 0, 1f);
                break;
        }

        EditorGUILayout.BeginVertical(EditorStyles.helpBox);
        EditorGUILayout.LabelField("현재 상태", playerController.CurrentState.ToString(),
            EditorStyles.boldLabel);
        EditorGUILayout.EndVertical();
        
        EditorGUILayout.EndVertical();
        
        // 지면 접촉 상태
        GUI.backgroundColor = Color.white;
        EditorGUILayout.Space();
        EditorGUILayout.LabelField("캐릭터 위치 디버그 정보", EditorStyles.boldLabel);
        GUI.enabled = false;
        EditorGUILayout.Toggle("지면 접촉", playerController.IsGrounded);
        GUI.enabled = true;
        
        // 강제로 상태 변경 버튼
        EditorGUILayout.BeginHorizontal();
        
        if (GUILayout.Button("Idle"))
            playerController.SetState(PlayerState.Idle);
        if (GUILayout.Button("Move"))
            playerController.SetState(PlayerState.Move);
        if (GUILayout.Button("Jump"))
            playerController.SetState(PlayerState.Jump);
        if (GUILayout.Button("Attack"))
            playerController.SetState(PlayerState.Attack);
        if (GUILayout.Button("Hit"))
            playerController.SetState(PlayerState.Hit);
        if (GUILayout.Button("Dead"))
            playerController.SetState(PlayerState.Dead);
        
        EditorGUILayout.EndHorizontal();
    }

    private void OnEnable()
    {
        EditorApplication.update += OnEditorUpdate;
    }

    private void OnDisable()
    {
        EditorApplication.update -= OnEditorUpdate;
    }

    private void OnEditorUpdate()
    {
        if (target != null)
            Repaint();
    }
}

 

이렇게 붙게 된다. 

* 참고 GUL.enabled 는 이후에 그려지는 GUI 컨트롤들을 활성화/비활성화 시키는 것이다.

 


04.  BlendTree

 

 

Blend Tree

X값과 Y값을 토대로 다른 애니메이션을 재생할 예정

중간에 플레이어가 있다고 생각을 하고, 각 지점이 공격이 들어오는 지점이라고 생각

public void SetHit(EnemyController enemyController, Vector3 direction)
{
    if (CurrentState != PlayerState.Hit)
    {
        var attackPower = enemyController.AttackPower;
        _currentHealth -= attackPower;
        
        GameManager.Instance.SetHP((float)_currentHealth / maxHealth);
        
        if (_currentHealth <= 0)
        {
            SetState(PlayerState.Dead);
        }
        else
        {
            SetState(PlayerState.Hit);
            Animator.SetFloat("HitPosX", -direction.x);
            Animator.SetFloat("HitPosZ", -direction.z);
        }
    }
}

 

다음과 같은 방식으로 맞을 때의 방향을 받아서, 정해진 애니메이션을 섞어서 표현.

 

블렌드 트리도 나중에 따로 포스팅할 예정.

 

 


05. 렉돌 만들기

 

렉돌이란 ?

캐릭터나 오브젝트의 움직임을 미리 정의된 애니메이션 대신 물리 엔진의 법칙에 따라 시뮬레이션 하는 방식

마치 헝겊 인형(rag doll)처럼 캐릭터의 각 관절이 물리적인 힘과 토크의 영향을 받아 자유롭게 움직이고 회전/충돌/중력의 영향을 받도록 하는 방법. 

 

촘퍼(몬스터)의 관절을 만들어 주어야 한다. 관절을다 만들고 힘이 다 빠진 상태를 연출할 것.

관절 부분에는 폴리곤이 다른 부분보다 많아야 자연스러운 관절 표현이 가능하다.

 

이런 식으로 조인트를 만들어준다.

모양과 알맞게 배치한다.

상하좌우에서 보는 기능을 활용하는 것도 좋은 방법이다.

관절관절을 만들어준다.

발가락까지 만들어주면 끝 !

 

렉돌들의 관전들을 다 저장해두고, 

    private void SetRagdollEnabled(bool isEnabled)
    {
        foreach (var ragdollCollider in ragdollColliders)
        {
            ragdollCollider.enabled = isEnabled;
        }

        foreach (var ragdollRigidbody in ragdollRigidbodies)
        {
            ragdollRigidbody.detectCollisions = isEnabled;
            ragdollRigidbody.isKinematic = !isEnabled;
        }
        
        EnemyAnimator.enabled = !isEnabled;
        
        _collider.enabled = !isEnabled;
        _rigidbody.detectCollisions = !isEnabled;
        
        EnemyAnimator.Rebind();
        EnemyAnimator.Update(0f);
    }

 

detectCollisions 는 다른 콜라이더와 충동했을 때 충돌을 감지할지를 결정하는 컴포넌트이다. 

위 함수는 전체적으로 렉돌의 기능을 구현했다. ,  각각의 관절들의 콜라이더, detectCollisions, isKinematic 정보들과

전체적인 ( 몬스터의 ) 콜라이더, 리지드바디, 애니메이터를 끔으로써 폭싹 앉아서 죽는 효과를 구현할 수 있다.

 


 

 

06. 무기 충돌

 

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

public class WeaponController : MonoBehaviour, IObservable<GameObject>
{
    [Serializable]
    public class WeaponTriggerZone
    {
        public Vector3 position;
        public float radius;
    }
    
    [SerializeField] private WeaponTriggerZone[] _triggerZones;
    
    public int AttackPower => attackPower;
    [SerializeField] private int attackPower;
    [SerializeField] private LayerMask targetLayerMask;
    
    private List<IObserver<GameObject>> _observers = new List<IObserver<GameObject>>();
    
    // ---
    // 충돌 처리
    private Vector3[] _previousPositions;
    private HashSet<Collider> _hitColliders;
    private Ray _ray = new Ray();
    private RaycastHit[] _hits = new RaycastHit[10];
    private bool _isAttacking = false;

    private void Start()
    {
        _previousPositions = new Vector3[_triggerZones.Length];
        _hitColliders = new HashSet<Collider>();
    }

    public void AttackStart()
    {
        _isAttacking = true;
        _hitColliders.Clear();

        for (int i = 0; i < _triggerZones.Length; i++)
        {
            _previousPositions[i] = transform.position + transform.TransformVector(_triggerZones[i].position);
        }
    }

    public void AttackEnd()
    {
        _isAttacking = false;
    }

    private void FixedUpdate()
    {
        if (_isAttacking)
        {
            for (int i = 0; i < _triggerZones.Length; i++)
            {
                var worldPosition = transform.position + 
                                    transform.TransformVector(_triggerZones[i].position);
                var direction = worldPosition - _previousPositions[i];
                _ray.origin = _previousPositions[i];
                _ray.direction = direction;
                
                var hitCount = Physics.SphereCastNonAlloc(_ray, 
                    _triggerZones[i].radius, _hits, 
                    direction.magnitude, targetLayerMask,
                    QueryTriggerInteraction.UseGlobal);
                for (int j = 0; j < hitCount; j++)
                {
                    var hit = _hits[j];
                    if (!_hitColliders.Contains(hit.collider))
                    {
                        // Time.timeScale = 0f;
                        // StartCoroutine(ResumeTimeScale());
                        
                        _hitColliders.Add(hit.collider);
                        Notify(hit.collider.gameObject);
                    }
                }
                _previousPositions[i] = worldPosition;
            }
        }
    }

    private IEnumerator ResumeTimeScale()
    {
        yield return new WaitForSecondsRealtime(10f);
        Time.timeScale = 1f;
    }

    public void Subscribe(IObserver<GameObject> observer)
    {
        if (!_observers.Contains(observer))
        {
            _observers.Add(observer);
        }
    }

    public void Unsubscribe(IObserver<GameObject> observer)
    {
        _observers.Remove(observer);
    }

    public void Notify(GameObject value)
    {
        foreach (var observer in _observers)
        {
            observer.OnNext(value);
        }
    }

    private void OnDestroy()
    {
        var copyObservers = new List<IObserver<GameObject>>(_observers);
        foreach (var observer in copyObservers)
        {
            observer.OnCompleted();
        }
        _observers.Clear();
    }
    
#if UNITY_EDITOR
    
    private void OnDrawGizmos()
    {
        if (_isAttacking)
        {
            for (int i = 0; i < _triggerZones.Length; i++)
            {
                var worldPosition = transform.position +
                                    transform.TransformVector(_triggerZones[i].position);
                var direction = worldPosition - _previousPositions[i];
                
                Gizmos.color = Color.green;
                Gizmos.DrawWireSphere(worldPosition, _triggerZones[i].radius);
                
                Gizmos.color = Color.red;
                Gizmos.DrawWireSphere(worldPosition + direction, _triggerZones[i].radius);
            }
        }
        else
        {
            foreach (var triggerZone in _triggerZones)
            {
                Gizmos.color = Color.green;
                Gizmos.DrawSphere(triggerZone.position, triggerZone.radius);
            }   
        }
    }
    
#endif
}

 

여기서 주의해서 봐야할 점은 

    private void FixedUpdate()
    {
        if (_isAttacking)
        {
            for (int i = 0; i < _triggerZones.Length; i++)
            {
                var worldPosition = transform.position + 
                                    transform.TransformVector(_triggerZones[i].position);
                var direction = worldPosition - _previousPositions[i];
                _ray.origin = _previousPositions[i];
                _ray.direction = direction;
                
                var hitCount = Physics.SphereCastNonAlloc(_ray, 
                    _triggerZones[i].radius, _hits, 
                    direction.magnitude, targetLayerMask,
                    QueryTriggerInteraction.UseGlobal);
                for (int j = 0; j < hitCount; j++)
                {
                    var hit = _hits[j];
                    if (!_hitColliders.Contains(hit.collider))
                    {
                        // Time.timeScale = 0f;
                        // StartCoroutine(ResumeTimeScale());
                        
                        _hitColliders.Add(hit.collider);
                        Notify(hit.collider.gameObject);
                    }
                }
                _previousPositions[i] = worldPosition;
            }
        }
    }

 

이 부분이다, 전의 포지션 정보와, 지금의 포지션 정보를 잇는 레이를 만들어 충돌이 발생한다면 충돌이 발생했다고

맞은 지점의 collider의 GameObject에게 알린다. 

 

transform.TransfomrVector 함수가 작동하는 방식은 이렇다.

1. _triggerZones[i].position은 자식 오브젝트 (triggerZone)의 로컬 포지션이다. 무기 오브젝트 기준으로 상대적인 오프셋을 나타낸다.

2. transform.TransformVector() 함수는transform을 기준으로 입력 벡터에 자신의(transform)의 회전과 Scale만을 사용한 결과를 반환한다. 

3. 이를 바탕으로 transform의 크기나 회전 값이 바뀌어도, _triggerZones[i].position이 부모 오브젝트의 회전과 스케일에 맞춰 월드 기준에서의 방향과 크기를 갖게 된다. 

 

만일 이런 방법을 사용하지 않는다면, triggerZone의 방향값이나 스케일은 부모 오브젝트에 따라 바뀌지 않으므로 트리거 존들이 항상 무기에 상대적으로 올바른 위치에 존재하지 않게된다. 

 

질문 : transform.TransformVector() 에 관하여

 

처음에 빠르게 transform.TransformVector()  함수를 찾아봤을 때 다음과 같은 설명이 나왔다.

TransformVector()는 **로컬 좌표계(Local Space)**의 벡터를
**월드 좌표계(World Space)**로 변환하는 함수입니다.

회전과 스케일만을 적용하고 position을 무시한 방향 벡터로 변환하는 함수라고 한다. 

 

그런데 이해가 안갔다, position을 무시한다면 모든 triggerZone의 위치들이 통일되고 다른 회전값만 지니게 되는 줄 알았다.

그래서 계속 물어봤는데 GPT와 제미나이, 클로드 모두 내 핀트를 못잡아줘서 한 삼십분 헤맸다. 

 

 결론은 TransformVector()는 부모 오브젝트의 회전과 스케일을 고려하여 월드 좌표계에서의 해당 벡터를 계산해주는 함수다. 그러니까 매개변수로 들어간 오브젝트의 벡터를 (종속되어 있는) 부모 오브젝트 기준으로 수정하여 반환하고, 이를 바탕으로 월드 포지션을 구할 수 있는 함수다.

 

 

매우 빠르거나 얇은 오브젝트가 다른 오브젝트와 충돌 판정을 제대로 거치지 않고 통과하는 현상인 터널링 현상 (Tunneling Effect ) 을 방지하기 위한 방식이다.  이 현상이 발생하는 이유는 게임 엔진은 일반적으로 매 프레임마다 오브젝트의 위치를 업데이트하고, 그 위치를 기준으로 충돌 여부를 검사한다. 만일 오브젝트의 이동 속도가 너무 빠르거나 충돌체의 크기가 작거나 얇다면

한 프레임에서 다음 프레임으로 이동하는 사이에 충돌체가 서로 "건너뛰어" 버릴 수 있다. 

때문에, 이를 방지하기 위해서 다양한 방법을 사용할 수 있고 위 방법은 그 중 하나의 방법이다. 

 

 


07. Addressable

 

지금까지는  Resources 파일에서 이름으로 파일을 선택하고 생성했다. (프리팹 등 )

최신 버전에서는 Addressable이라는 패키지가 있어 단순히 동적으로 파일을 생성하는 것 이외에도 다른 부가적인 기능을 사용할 수 있다. 대표적으로 비동기로 프리팹들을 로드할 수 있고, 원격에 있는 파일을 로드할 수도 있다.

 

왜 이렇게 동적으로 파일을 생성하려고 할까 ?  씬 안에 존재하는 것은 메모리에 존재하는 것이다.

메모리에 한 번에 너무 많은 파일들이 생성되게 하려면 성능/시간 상의 제약에 걸릴수 있으므로

Prefab화 시켜 저장해둔 뒤 , 나중에 동적으로 생성함으로써 메모리와 로딩시간을 절약할 수 있다.

 

 

* 참고 : unity는 사용되는 것들만 빌드한다는 특징이 있음, 하지만 Resorces 폴더에 있는 파일들은 무조건 포함됨.

 

Addressable을 패키지 매니저에서 설치해준 후 

Prefab인 파일을 선택해주면 Addressable라는 체크박스가 나타나게 된다.

프리팹 파일 뿐만 아니라 Texture, Sound 파일도 있다.

 

Addressables Group 창이 뜬다.

크게는 그룹으로 나눌 수 있고, 그 안에서도 라벨을 선택해서 나눌 수 있다.

 

기본값은 폴더명도 포함되지만, 편하게 관리하기 위해서 이름으로 해도 된다. ( 중복 조심 ) 

이 이름은 Addressable Object를 식별할 수 있는 식별값이 되기 때문에 제대로 설정해야 한다. 

 

AsyncOperationHandle<T>는 유니티의 Addressables 시스템에서 비동기 작업의 상태와 결과를 관리하는 데 사용되는 구조체다. 여기서 T는 비동기 작업의 결과 타입을 나타내는 제네릭 타입 파라미터다. 

 

InstantiateAssync, ReleaseInstace을 통해 생성하는 방법이 가장 간단하고

나머지 속성을 설정하려면 다른 기능들을 활용하는 것이 좋다.

https://docs.unity3d.com/Packages/com.unity.addressables@latest

나중에 확인해볼 주소다. 

 

 

 


 

08. 카메라 위치 제한

 

 

장애물이 있을 때 레이어를 알기 위해서 obstacleLayerMask 설정

// 카메라와 타겟 사이에 장애물이 있을 때 카메라와 타겟간의 거리를 조절하는 함수
private float AdjustCameraDistance()
{
    var currentDistance = distance;
    
    // 타겟에서 카메라 방향으로 레이케이스를 발사
    Vector3 direction = GetCameraPosition(1f, _polarAngle, _azimuthAngle).normalized;
    RaycastHit hit;

    // 타겟에서 카메라 예정 위치까지 레이케이스 발사
    if (Physics.Raycast(_target.position, -direction, out hit, 
            distance, obstacleLayerMask))
    {
        float offset = 0.3f;
        currentDistance = hit.distance - offset;
        currentDistance = Mathf.Max(currentDistance, 0.5f);
    }
    return currentDistance;
}

 

카메라가 벽을 통과하지 않게 하기 위한 함수다.

offSet으로 벽 앞에서 카메라를 두기 위한 여유 거리를 설정해두고, 

타겟에서 카메라의 방향으로 레이케스트를 발사한다. 

만일, 레이케스트를 쐈을 때 설정해둔 장애물(obstacleLayerMask)이 있어 맞았다면 , 

그 벽면으로부터 offset만틈 띄운 거리를 currentDistance에 할당하고

currentDistance를 리턴하는데, 너무 카메라와 타겟이 가까워 질 수 있으므로 0.5라는 최솟값도 설정한다.

 

 


 

09. 자투리

 

빨간색으로 하면 사라진다..왜 ? 

 

텍스쳐는 다른 세 종류의 알베도로 되어있고, 셰이더 내부에서 버택스, 해당 점 위치에 색이 빨간색으로 지정이 되면

해당 텍스쳐는 실현이 되지 않는 형태로 구현되어 있음.

 

 

바닥은 어쩔수 없이 meshCollider를 사용했지만, 벽이나 꽃은 가급적이면 박스 원통 콜라이더를 써라. 

 

 

reflection prob를 넣어주면 주변 공간들이 reflection 되는 형태를 갖게 됨. ( 유리가 아닌 스틸 재질도 마찬가지 ) 

 

 

 

Skinned Mesh Render에서 Matrial에서 섞어서 표정을 만들고 할 수도 있다.

 

HP Bar를 만들 것인데 Camaer를 worldSpace로 하면 Canvas도 Vector3값을 지니게 된다.

 

Editor를 관리할거면 Editor 폴더에, 동적으로 생성할거면 Resources폴더에 생성