본문 바로가기

Unity Engine/기초 테크닉

유니티 Raycast로 3D 오브젝트 클릭과 드래그

728x90
728x90

https://github.com/nicotina04/UnityDrag3DObject

 

GitHub - nicotina04/UnityDrag3DObject: An implementation of dragging object and it's example

An implementation of dragging object and it's example - GitHub - nicotina04/UnityDrag3DObject: An implementation of dragging object and it's example

github.com

 

이번 포스팅에서는 씬에 존재하는 3D 오브젝트를 마우스로 클릭, 그리고 드래그하는 방법을 알아본다.

 

그전에 어떻게 마우스의 입력이 게임 내의 오브젝트와 상호작용할 수 있는지 간단하게 알아본다. 이미 이론적 배경을 아는 사람은 아래의 접은 글을 건너뛰어도 좋다.

좌표계 변환

더보기

지금 여러분이 보고 있는 그 액정 화면에도 좌표계가 존재한다. 가령 마우스 커서가 좌측 상단에 위치하고 있으면 그 커서의 좌표는 스크린 좌표계를 기준으로 (0, 0)이 된다.

 

그리고 유니티 에디터에서 존재하는 어떤 오브젝트는 월드 좌표계(글로벌 스페이스)를 기준으로 만드는 사람이 지정한 어떤 좌표에 놓여있을 것이다.

 

그렇다면 여기서 의문이 하나 생긴다.

 

"어 그러면 마우스 커서나 터치는 스크린 좌표계의 좌표로 표현될 텐데 이를 어떻게 글로벌 스페이스의 좌표로 바꿀 수 있는 거지?"

 

이를 이해하기 위해선 transformation을 이해해야 한다. 이 주제에 한정해서 이야기하면 오브젝트의 월드 좌표를 카메라 좌표로, 카메라 좌표를 스크린 좌표로 변환하는 것이라 말할 수 있다. 물론 그 반대로 변환하는 것도 가능하다.

 

여기까지 봤으면 "아하 그러면 transformation이라는 과정을 통해 스크린 좌표계의 마우스의 좌표를 월드 좌표로 바꿔서 해당 좌표에 위치한 오브젝트를 찾을 수 있구나?"와 같은 결론에 도달할 수 있다.

 

과정을 자세히 설명하기엔 글 주제를 벗어나니 이론을 자세히 알아보고자 하면 아래 링크들을 참고하자.(선형대수학을 할 줄 알아야 한다.)

 

https://darkpgmr.tistory.com/84

https://pjnull.tistory.com/13

OnMouse

유니티는 오브젝트를 마우스 클릭할 때 다양한 행동을 할 수 있도록 OnMouse 접두사로 시작하는 다양한 메서드를 MonoBehaviour에서 지원한다.

 

이번 포스트에서는 드래그에 사용되는 3가지 메서드를 쓸 것인데 이를 간략히 알아보도록 하자.

 

참고로 OnMouse가 정상적으로 작동하려면 콜라이더가 있어야 함을 명시한다.

 

OnMouseUp: 해당 오브젝트의 클릭을 종료했을 때(클릭 버튼을 뗐을 때) 동작을 정의하는 메서드이다.

OnMouseDown: 해당 오브젝트를 클릭했을 때 동작을 정의하는 메서드이다.

OnMouseDrag: 해당 오브젝트를 드래그했을 때 동작을 정의하는 메서드이다.

오브젝트 클릭

그럼 이제 오브젝트를 클릭했을 때 오브젝트의 이름을 출력하도록 하자. OnMouseDown을 활용한다.

 

 

적당하게 오브젝트들을 배치해준다.

 

 

Raycast를 사용해서 충돌시킬 것이기 때문에 콜라이더가 있는지 확인하고 없으면 추가하도록 한다.

 

그리고 적당히 마우스 조작을 하는 스크립트를 부착할 오브젝트를 만들고 스크립트를 만들어 붙여주자.

 

ScreenPointToRay

이제 스크린 좌표를 기준으로 ray를 쏴서 오브젝트와 충돌시켜보겠다. 어떤 스크린 좌표계의 좌표를 글로벌 스페이스의 ray로 바꾸기 위해 ScreenPointToRay 메서드를 사용할 수 있다.

 

Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);

 

위 코드는 메인 카메라로 렌더링 되는 화면의 스크린 좌표계에서 마우스의 현재 좌표를 관통하는 ray를 생성한다. 이제 Raycast로 얻어낸 ray를 쏘면 충돌하는 물체를 얻어낼 수 있다.

 

마우스를 왼쪽 클릭했을 때 raycast를 하는 코드는 여기까지 쓸 수 있겠다.

 

private void OnMouseDown()
{
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    RaycastHit hit;
    if (Physics.Raycast(ray, out hit))
    {
        // do something
    }
}

 

Raycast가 성공했을 때 hit에서 트랜스폼을 꺼내와 이름을 출력하도록 해보자.

 

Debug.Log(hit.transform.name);

 

최종 코드는 다음과 같이 된다.

 

private void OnMouseDown()
{
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    RaycastHit hit;
    if (Physics.Raycast(ray, out hit))
    {
        Debug.Log(hit.transform.name);
    }
}

 

실행을 하고 오브젝트들을 눌러서 이름이 정상적으로 출력되는지 확인하자.

 

 

여기까지 오브젝트를 클릭하여 상호작용 하는 것을 성공했다.

오브젝트 드래그

다음으로 오브젝트를 드래그해보도록 하자. 오브젝트를 드래그하는 건 클릭보다 어렵다. 이 파트에서는 단순한 구현부터 시작해 점차 보정해나가면서 자연스러운 드래그를 구현할 것이다.

 

참고로 바닥이 있는 씬이라 가정하고 진행한다. 바닥에 해당하는 오브젝트에 레이어 마스크를 부여한 후 활용할 것이기 때문이다.

Step 1. 가장 단순한 방법

지금부터 할 가장 간단한 방법은 OnMouseDrag만 사용하여 드래그를 하는 것이다.

 

일단 바닥에 레이어를 하나 할당한다.

 

 

그리고 드래그를 담당하는 스크립트를 만든 후 OnMouseDrag를 다음과 같이 구현하도록 한다.

 

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

public class DragCubeNaive : MonoBehaviour
{
    RaycastHit hit;
    float fixedY;
    
    void Awake()
    {
        fixedY = transform.position.y;
    }

    private void OnMouseDrag()
    {
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 마우스 좌표에서 쏘는 ray

        // 바닥에 ray hit이 된 경우 오브젝트의 좌표를 충돌된 좌표로 바꾼다..
        // 물론 y좌표는 바뀌면 안되므로 사전에 저장한 fixedY로 바꾸도록 한다.
        if (Physics.Raycast(ray, out hit, Mathf.Infinity, LayerMask.GetMask("Floor")))
        {
            Vector3 nextPos = hit.point;
            nextPos.y = fixedY;
            transform.position = nextPos;
        }
    }
}

 

이렇게 하면 오브젝트가 드래그 상태일 때 raycast가 계속 일어나면서 오브젝트가 충돌 위치로 이동하게 된다.

 

 

일단 훌륭하게도 잘 작동한다. 하지만 좌표의 기준이 바닥이기 때문에 클릭하자마자 오브젝트가 순간 이동하게 되어 다소 어색해 보인다.

Step 2. 이동 보정

그러면 오브젝트가 순간이동하지 않도록 해보자. ray와 바닥이 최초로 충돌할 때 보정치를 저장해서 순간 이동을 하지 않도록 할 것이다.

 

 

이렇게 대상 오브젝트의 위치와 raycast의 충돌 좌표 사이를 벡터 dist로 저장해 dist 만큼 다시 빼줄 것이다.

 

이렇게 하면 y 좌표는 자동으로 보정되므로 fixedY가 필요 없다.

 

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

public class DragCubeNaive : MonoBehaviour
{
    RaycastHit hit;
    Vector3 dist;
    
    void Awake()
    {
        dist = Vector3.zero;
    }

    private void OnMouseUp()
    {
        dist = Vector3.zero; // 마우스를 클릭을 끝내면 영벡터로 초기화 한다.
    }

    private void OnMouseDrag()
    {
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);

        if (Physics.Raycast(ray, out hit, Mathf.Infinity, LayerMask.GetMask("Floor")))
        {
            if (dist == Vector3.zero) // 영벡터이면 dist를 계산한다.
            {
                dist = hit.point - transform.position;
            }
            
            transform.position = hit.point - dist; // dist를 빼서 위치를 보정한다.
        }
    }
}

 

 

좀 더 부드러운 드래그를 구현했다. 하지만 여기저기 드래그를 할 때 커서와 오브젝트가 서로 엇갈리는 것을 볼 수 있다.

 

 

이유는 새로운 ray를 쏠 때 dist가 갱신되지 않는다는 것이다. dist가 갱신되지 않으면 잘못된 보정이 들어가 커서와 오브젝트의 위치의 싱크가 맞지 않게 되기 때문이다.

 

그렇다고 매번 dist를 갱신하면 ray가 바뀔 때마다 같이 바뀐 dist가 더해지면서 결국 오브젝트는 움직이지 않게 된다.

 

이제 우리는 다른 방법을 강구할 필요가 있다.

Step 3. 삼분 탐색

Step2에서 주요 문제는 ray 방향에 따른 dist를 적절히 갱신할 수 없어서였다.

 

발상을 바꿔보자. 오브젝트와 ray가 충돌한 좌표를 활용하면 어떨까?

 

아이디어는 다음과 같다.

 

OnMouseDown을 할 때 raycast를 하여 오브젝트와 충돌시킨다.

 

새로운 오브젝트 DragAnchor를 만들어 해당 오브젝트의 위치를 충돌 지점의 좌표로 설정한다.

 

그리고 DragAnchor를 드래그할 오브젝트의 부모로 설정한다. 그렇게 하면 DragAnchor가 이동할 때 원래 드래그하기로 한 오브젝트가 같이 따라올 것이다.

 

DragAnchor를 이동시키기 위해선 OnMouseDrag가 호출될 때 ray가 바닥과 충돌한 좌표를 보정한 좌표로 이동해야 한다. 그냥 바닥 좌표로 가면 순간 이동을 하므로 step 2에서 했던 것처럼 마우스 위치와 맞추는 작업을 할 필요가 있다.

 

어떻게 맞춰야 할까? 우리는 최초 오브젝트와 충돌한 y좌표를 알고 있다.(OnMouseDown에서 raycast 했다.) 드래그를 할 때 카메라와 ray가 충돌한 바닥 좌표 사이에서 y좌표가 같게 되는 x와 z 좌표를 찾으면 된다. 그것이 바로 DragAnchor가 이동할 좌표가 된다.

 

 

그러면 정확히 뭐를 활용해서 구해야 할까? 이 문제를 해결하기 위해 카메라와 바닥 사이의 벡터와 삼분 탐색을 사용할 것이다.

 

 

충돌한 바닥 좌표에서 카메라의 좌표를 빼면 위와 같은 모양의 벡터가 나온다. 그리고 위에서 말한 y 좌표는 저 벡터 위의 어떤 점이 될 것이다. 그런데 그 점이 정확히 어디인지는 모르므로 위의 벡터 비율을 1로 하여금 y가 동일해지는 좌표로 이동하는 적절한 비율을 삼분 탐색으로 구한다.

 

 

0부터 1 사이의 비율에 대해 구한 벡터의 y좌표와 목표로 하는 y좌표의 차의 절댓값을 가지고 그래프를 그리면 다음과 같이 된다.

 

 

최솟값이 0 하나인 유니모달 함수의 형태를 띰을 볼 수 있고, 삼분 탐색을 사용할 조건이 갖춰진다.

 

삼분 탐색을 통해 적절한 좌표를 찾으면 되는 것이다.

구현

자 이제 구현을 해보자. 클래스 멤버로 오브젝트 충돌과 바닥 충돌을 저장할 RaycastHit와 DragAnchor를 생성할 GameObject, 처음 카메라 좌표를 저장할 Vector3가 필요하다.

 

RaycastHit hit, hitFloor;
GameObject dragAnchor;
Vector3 mainCamPosition;

void Start()
{
    mainCamPosition = Camera.main.transform.position;
}

OnMouseDown

OnMouseDown에서는 DragAnchor를 생성하고 좌표를 ray와 충돌한 오브젝트의 좌표로 설정하면 된다.

 

private void OnMouseDown()
{
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    if (Physics.Raycast(ray, out hit))
    {
        dragAnchor = new GameObject("DragAnchor");
        dragAnchor.transform.position = hit.point;
        transform.SetParent(dragAnchor.transform);
     }
}

OnMouseUp

마우스를 떼면 부모 관계를 돌리고 DragAnchor를 제거하는 것을 잊지 말자.

 

private void OnMouseUp()
{
    transform.SetParent(null);
    Destroy(dragAnchor);
}

OnMouseDrag

OnMouseDrag에서는 바닥과 충돌한 좌표와 현재 카메라 좌표를 뺀 벡터 camToFloor를 계산하고 위에서 설명한 삼분 탐색을 통해 오브젝트가 움직일 적절한 위치를 찾아 DragAnchor에 넣어주면 된다.

 

private void OnMouseDrag()
{
    Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    if (Physics.Raycast(ray, out hitFloor, Mathf.Infinity, LayerMask.GetMask("Floor")))
    {
        // 계산에 기준이 되는 y좌표인 h
        float h = dragAnchor.transform.position.y;

        // 카메라 -> 바닥 방향 이동 벡터를 구하고 이동할 다음 위치를 초기화
        Vector3 camToFloor = hitFloor.point - Camera.main.transform.position;
        Vector3 nextPosition = Vector3.zero;

        // 비율을 대상으로 삼분 탐색 수행. iteration이 40번을 넘기면 안정적인 값이 나온다.
        float lo = 0.0f, hi = 1.0f;
        for (int i = 0; i < 38; i++)
        {
            float diff = hi - lo;
            float p1 = lo + diff / 3;
            float p2 = hi - diff / 3;

            // 카메라 좌표에서 이동 벡터 * 비율(p1 or p2)만큼 이동
            var v1 = mainCamPosition + camToFloor * p1;
            var v2 = mainCamPosition + camToFloor * p2;
            if (Mathf.Abs(v1.y - h) > Mathf.Abs(v2.y - h))
            {
                nextPosition = v2;
                lo = p1;
            }
            else
            {
                nextPosition = v1;
                hi = p2;
            }
        }

        // nextPosition으로 이동
        dragAnchor.transform.position = nextPosition;
    }
}

 

최종 드래그를 구현하는 클래스는 아래와 같이 된다.

 

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

public class Draggable : MonoBehaviour
{
    RaycastHit hit, hitFloor;
    GameObject dragAnchor;
    Vector3 mainCamPosition;

    void Start()
    {
        mainCamPosition = Camera.main.transform.position;
    }

    private void OnMouseUp()
    {
        transform.SetParent(null);
        Destroy(dragAnchor);
    }

    private void OnMouseDown()
    {
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        if (Physics.Raycast(ray, out hit))
        {
            dragAnchor = new GameObject("DragAnchor");
            dragAnchor.transform.position = hit.point;
            transform.SetParent(dragAnchor.transform);
        }
    }

    private void OnMouseDrag()
    {
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        if (Physics.Raycast(ray, out hitFloor, Mathf.Infinity, LayerMask.GetMask("Floor")))
        {
            float h = dragAnchor.transform.position.y;

            Vector3 camToFloor = hitFloor.point - Camera.main.transform.position;
            Vector3 nextPosition = Vector3.zero;

            float lo = 0.0f, hi = 1.0f; // ratio
            for (int i = 0; i < 38; i++)
            {
                float diff = hi - lo;
                float p1 = lo + diff / 3;
                float p2 = hi - diff / 3;

                var v1 = mainCamPosition + camToFloor * p1;
                var v2 = mainCamPosition + camToFloor * p2;
                if (Mathf.Abs(v1.y - h) > Mathf.Abs(v2.y - h))
                {
                    nextPosition = v2;
                    lo = p1;
                }
                else
                {
                    nextPosition = v1;
                    hi = p2;
                }
            }

            dragAnchor.transform.position = nextPosition;
        }
    }
}

 

이제 실행하면 실수 오차로 인해 위치가 아주 미세하게 바뀌는 것을 제외하면 아름답게 드래그가 되는 것을 볼 수 있다.

 

 

지금까지 유니티에서 3D 오브젝트를 드래그하는 방법을 알아보았다. 이제 여기서 배운 것을 잘 활용하여 드래그를 잘해보자.

728x90
728x90

'Unity Engine > 기초 테크닉' 카테고리의 다른 글

[Unity] 유니티 오브젝트의 null 확인  (0) 2023.09.03