본문 바로가기
Unity

[Unity] 목표 지점을 바라보도록 객체를 회전시키는 유니티 내장 함수 소개 및 직접 스크립트로 구현하기(삼각함수, 벡터 연산 활용)

by RucA 2024. 5. 13.
728x90
반응형

3인칭 게임에서 자신의 캐릭터를 화면 내 마우스가 클릭한 곳으로 이동하는 스크립트를 작성하는 도중, 회전에 대한 이해가 직관적으로 되지 않아 회전에 필요한 함수를 직접 구현해보는 시간을 가졌다. 포스팅 후 내용을 더 정리해서 추가적으로 3인칭 게임에서의 클릭 인식과 캐릭터 이동, 회전을 모두 포함한 글도 작성할 예정이다.

 

 

목표 지점을 가리키는 단위 방향 벡터 구하기


방향을 구하는 방법은 벡터 값인 위치 데이터를 활용해 구할 수 있다. 이후 진행할 각도를 구하는 연산의 편의성을 위해, 방향의 크기를 1로 조정해주는 정규화를 진행한다. 회전이 아닌 단순 이동의 경우에도 정규화가 필요한데, 이는 정규화를 진행하지 않는다면, 대각선 방향에서의 크기가 더 커지기 때문이다. 유니티에서 정규화는 .Normalize() 메소드나 .normalized 속성을 활용해 구할 수 있다.

 

  • 방향 벡터 구하기
//목표 지점 위치 정보(벡터)
private Vector3 targetPosition;

//위치 정보를 바탕으로 방향 벡터를 구한다.
Vector3 direction = targetPosition - transform.position;

 

  • 단위 방향 벡터 구하기 (정규화)
//방향 벡터를 정규화하는 두가지 방법
direction.Nomalize();	//메소드 방식 : 해당 벡터를 정규화한다.(원본이 변함)
direction.normalized;	//속성 방식 : 해당 벡터의 정규화한 벡터를 반환한다.(원본은 그대로)

 

 

유니티 내장 함수를 활용해 지정 위치를 바라보도록 객체 회전하기 


유니티의 회전에 관련된 내장 함수는 대부분 Quaternion이라는 클래스에 구현이 되어 있다. 이는 유니티가 사원수를 기반으로 회전을 처리하기 때문이다. 내장 함수로 회전을 간편하게 구현할 수 있다. 급하게 사용해야 하는 사람들을 위해 바로 관련 코드부터 올리겠다.

 

  • 유니티 내장 함수를 활용해 회전하기
//위에서 구한 목표 방향(Vector3)을 사분위수로 전환하는 메서드
Quaternion targetRotation = Quaternion.LookRotation(direction);

//(시작값, 목표값, 회전 속도)를 인자로 받아 회전 값을 연산해주는 메서드
Quaternion rotateAmount = Quaternion.RotateTowards(transform.rotation, targetRotation, rotateSpeed * Time.deltaTime);

//회전값 적용
transform.rotation = rotateAmount;

 

위와 같은 방식으로 유니티 내장 함수를 사용해 부드럽게 회전하는 코드를 만들 수 있다. 사원수와 같은 수학 개념은 나중에 구체적으로 다루겠지만, 사실 난 내장 함수를 직관적으로 이해가 되지 않은 채로 사용하려니 찝찝해서 나만의 커스텀 회전을 구현해보았다.

 

 

직접 객체 회전(y축) 스크립트 작성하기


나만의 커스텀 회전 스크립트를 작성해보았다. 우선 위에서 구한 목표를 가리키는 단위 방향 벡터와 삼각함수를 활용해 회전해야 하는 각도를 구하고, 덜덜 떨리는 방식을 없애기 위해 여러가지 시도를 했다. 사실 제일 당황했던 건 초반에 계산했던 방식과 아예 다르게 동작해서 놀랐는데, 이는 유니티의 공간좌표와 y축의 각도의 방향이 당연히 같을 것이라 상정(이는 텍스트로 설명하기엔 애매하지만, 아래의 손그림 설명을 통해 직관적으로 이해할 수 있다.)하고 설계했던 내 잘못이었다... 같은 삽질을 반복하지 않도록 그림과 코드를 통해 정리해보겠다. 악필 주의

 

  • 삼각함수를 활용해 회전해야 하는 각도(y축) 구하기

객체의 각도로 현재 객체가 바라보고 있는 방향을 벡터로 변환
객체의 각도로 현재 객체가 바라보고 있는 방향을 벡터로 변환

 

위의 손그림과 같이 현재 캐릭터(객체)의 각도를 활용해 유니티 3차원 좌표에서 현재 캐릭터가 바라보고 있는 방향을 벡터로 변환할 수 있다. 단, 여기서 y축의 회전 각도(transform.rotation.eulerAngles.y)가 곧 객체의 각도라고 여기면 안된다. 

이걸 몰라서 엄한 곳을 파면서 계속 헤맸었는데, y축 회전값이 의미하는 것과 우리가 활용하고자 하는 각도의 차이를 손그림으로 직관적으로 표현하면 다음과 같다. 이때 인자나 반환값이 라디안인지 디그리인지 체크하는 것에 주의

 

(유니티 y 회전값과 세타가 다름 보이기)

유니티 y 회전과 세타가 방향, 기준이 다름
유니티 y 회전과 세타가 방향, 기준이 다름

 

따라서 y의 회전값을 먼저 우리에게 쉽게 활용할 수 있도록 변환하는 연산을 추가한다. 연산한 각도와 삼각함수를 통해 현재 캐릭터가 바라보고 있는 방향을 xz평면 상의 벡터로 표현할 수 있다. 관련 코드는 다음과 같다.

//유니티 내의 y축 회전 각도와 우리가 원하는 xz평면상의 각도는 다르다(변환 필요)
//y축 회전 각도는 디그리, Mathf의 삼각함수 메소드는 라디안을 사용(변환 필요)
float seta = (90 - now.eulerAngles.y)/180 * Mathf.PI; 

//연산해 구한 xz평면 상의 각도와 삼각함수를 활용해 벡터로 구한다.
float x = Mathf.Cos(seta);
float z = Mathf.Sin(seta);

 

벡터로 변환하는 이유는, 벡터의 내적과 외적을 통해 회전해야 하는 값의 크기와 방향을 쉽게 구할 수 있기 때문이다. 

 

삼각함수의 역함수로 각도를 구하는 공식
삼각함수의 역함수로 각도를 구하는 공식

 

여기서 내적과 외적 중 하나만 활용해도 삼각함수의 역함수를 활용해 각도를 구할 수 있다고 생각할 수 있다. 다만 삼각함수의 역함수의 경우, 각자 치역(출력값의 범위)이 다음과 같이 다르다. 따라서 내적을 통해 두 벡터의 사잇값(더 작은 값)의 절댓값을 구할 수 있고, 외적을 통해 두 벡터의 사잇값의 방향을 구할 수 있다. 

 

삼각함수의 역함수로 각도를 구하는 공식
삼각함수의 역함수로 각도를 구하는 공식

 

이제 내적과 외적을 직접 공식을 활용해 연산해서 구하고, 역함수를 통해 벡터의 사잇값을 구한다.

float inner = targetDirection.x * x + targetDirection.z * z;
float outer = targetDirection.x * z - targetDirection.z * x;

float delta1 = (Mathf.Acos(inner)*180)/Mathf.PI;
float delta2 = (Mathf.Asin(outer)*180)/Mathf.PI;

//외적을 통해 부호를 정하고, 내적을 통해 절댓값을 구한다.
float rotationAmount = (delta2 >= 0) ? delta1 : -delta1;

 

  • 종합 : 회전 각도를 구하는 커스텀 함수
private float CalculateRotationY(Quaternion now, Vector3 targetDirection)
    {
        float seta = (90 - now.eulerAngles.y)/180 * Mathf.PI; 
        float x = Mathf.Cos(seta);
        float z = Mathf.Sin(seta);

        float inner = targetDirection.x * x + targetDirection.z * z;
        float outer = targetDirection.x * z - targetDirection.z * x;

        float delta1 = (Mathf.Acos(inner)*180)/Mathf.PI;
        float delta2 = (Mathf.Asin(outer)*180)/Mathf.PI;
               
        //Debug.Log($" y각도 {now.eulerAngles.y} 내적 {(delta1)} 외적 {delta2}");

        float rotationAmount = (delta2 >= 0) ? delta1 : -delta1;

        return rotationAmount;
    }

 

 

회전 적용하기 : 자연스럽게 회전하는 스크립트 구현


구한 각도는 오일러 각이므로 이를 유니티에서 활용하는 사원수로 변환하고 이를 원래 회전값에 곱해주면 된다. 바로 바라보는 것이 아니라 천천히 지점을 바라보는 것을 구현하는 과정에서 약간 애를 먹었다. 만약 바로 목표 지점을 바라보는 것이 목적이라면 그냥 회전각 구하지 말고 맨 위에 구한 방향을 사원수 값으로 변환해 회전 값에 넣어주자.

 

  • 변수 하나만 사용하는 경우 : 점차 회전하는 속도가 느려짐
//목표 지점 위치 정보(벡터)
private Vector3 targetPosition;

//위치 정보를 바탕으로 방향 벡터를 구한다.
Vector3 direction = targetPosition - transform.position;

//정규화
direction.Normalize();

//회전각 구하고 사원수로 변환
float RotationAmountY = CalculateRotationY(transform.rotation, direction);
Quaternion rotationAmount = Quaternion.Euler(0, RotationAmountY * Time.deltaTime ,0);

//회전 연산
transform.rotation *= rotationAmount;

 

위 코드는 원래 ScreenCast를 학습하는 과정에서 쓴 코드의 일부로, 회전에 해당하는 부분만 가져왔다. 이 코드의 문제점은 현재 방향 벡터와 목표 방향 벡터가 회전하며 점점 각도가 좁아질수록 회전하는 속도가 느려져서 빠르게 회전하다가 거의 회전을 안하는 수준으로 느려진다는 문제가 있다. 나는 일정한 속도로 회전하는 캐릭터를 원했기 때문에 회전량을 총 두 변수에 담아 저장했다.

 

  • 변수 두 개 사용 : 회전하는 속도를 일관적으로 가져가기 위함 + 적절히 회전한 후 덜덜 떨리는 현상 없이 멈추기 위해
//목표 지점 위치 정보(벡터)
private Vector3 targetPosition;

//위치 정보를 바탕으로 방향 벡터를 구한다.
Vector3 direction = targetPosition - transform.position;

//정규화
direction.Normalize();

//변수 2개 사용
float fixedRotationAmountY = CalculateRotationY(transform.rotation, direction);
float rotationAmountY = fixedRotationAmountY;

//무한하게 회전하는 경우를 막기 위해 부호 체크
if (rotationAmountY * fixedRotationAmountY > 0)
        {
        	//지속적으로 일정한 속도로 회전하기 구현
            Quaternion rotationAmount = Quaternion.Euler(0, fixedRotationAmountY * Time.deltaTime ,0);
            rotationAmountY -= fixedRotationAmountY * Time.deltaTime;
            transform.rotation *= rotationAmount; 
        }

 

위의 코드를 일정한 속도로 회전하도록 바꾸며 발생한 문제로, 멈추지 않고 계속해서 회전하는 문제가 생겼다. 처음에는 if 조건문으로 회전량의 절댓값을 재면서 일정 이상일 경우에만 회전하도록 했지만, 이는 상황에 따라 정확하지 않으며, 절댓값을 크게 하면 눈에 보일 정도로 어긋난 것이 보였다. 따라서 절댓값이 아닌 부호를 체크하는 것으로 수정했다.

 

사실 위의 코드들은 회전에 관련한 부분만 추출한 것으로 각 함수는 사용자 입력 함수 안밖으로 분리되어 있다. 이해를 위해 다른 기능도 포함된 전체적인 코드도 첨부한다. 이후 코드와 관련한(ScreenCast를 활용해 마우스 클릭 이동 구현) 포스팅을 추가하겠다.

using UnityEngine;

public class ScreenRayCast : MonoBehaviour
{
    [SerializeField] private Vector3 targetPosition = new Vector3(0, 1, 0.001f);
    [SerializeField] private Camera mainCamera;
    [SerializeField] private float moveSpeed = 10f;
    [SerializeField] private float rotateSpeed = 180f;
    [SerializeField] private float fixedRotationAmountY = 0;
    [SerializeField] private float rotationAmountY = 0;
    
    private void Update()
    {
        //마우스 클릭 시, 화면에서 클릭한 방향으로 RayCast, 이후 Ray가 충돌한 위치 정보를 얻고 활용
        if (Input.GetMouseButtonDown(0) == true)
        {
            Ray ray = mainCamera.ScreenPointToRay(Input.mousePosition);
            
            if (Physics.Raycast(ray, out RaycastHit hit, 100f))
            {
                targetPosition = hit.point;
                targetPosition.y = transform.position.y;
                fixedRotationAmountY = CalculateRotationY(transform.rotation, (targetPosition - transform.position).normalized);
                rotationAmountY = fixedRotationAmountY;
            }
        }
        
        //Ray 충돌 위치 정보를 바탕으로 타겟 방향을 구한다.
        Vector3 direction = targetPosition - transform.position;
        
        //움직임 & 덜덜 떨리기 방지
        if (direction.magnitude >= 0.1)
        {
            Vector3 moveAmount = moveSpeed * Time.deltaTime * direction;
            transform.position += moveAmount;
        }
        
        direction.Normalize();
        
        //회전 & 적절히 멈추기
        if (rotationAmountY * fixedRotationAmountY > 0)
        {
            Quaternion rotationAmount = Quaternion.Euler(0, fixedRotationAmountY * Time.deltaTime ,0);
            rotationAmountY -= fixedRotationAmountY * Time.deltaTime;
            transform.rotation *= rotationAmount; 
        }
        //Quaternion targetRotation = Quaternion.LookRotation(direction);
        //Quaternion rotateAmount = Quaternion.RotateTowards(transform.rotation, targetRotation, rotateSpeed * Time.deltaTime);
        //transform.rotation = rotateAmount;
    }

    private float CalculateRotationY(Quaternion now, Vector3 targetDirection)
    {
        float seta = (90 - now.eulerAngles.y)/180 * Mathf.PI; 
        float x = Mathf.Cos(seta);
        float z = Mathf.Sin(seta);

        float inner = targetDirection.x * x + targetDirection.z * z;
        float outer = targetDirection.x * z - targetDirection.z * x;

        float delta1 = (Mathf.Acos(inner)*180)/Mathf.PI;
        float delta2 = (Mathf.Asin(outer)*180)/Mathf.PI;
               
        //Debug.Log($" y각도 {now.eulerAngles.y} 내적 {(delta1)} 외적 {delta2}");

        float rotationAmount = (delta2 >= 0) ? delta1 : -delta1;

        return rotationAmount;
    }
}

 

728x90
반응형