[Unity] 인풋시스템으로 모바일 카메라 회전 만들기

요구르트·2025년 3월 7일

Unity

목록 보기
2/7

핀치줌에 이어 모바일 화면을 드래그하면 카메라가 회전하는 기능을 만들어 보도록 하겠습니다.
저번 핀치줌을 구현하지 않아도 괜찮기는 한데, 저번에 설명한 건 건너뛸 예정인지라 처음 포스트를 보시는 분들은 진행하는데 어려움이 있을 수 있습니다.

Camera Rotate

카메라 회전은 다음과 같이 구현할 예정입니다.

어떤 오브젝트 안에 중심이 되는 빈 오브젝트를 만들어주고, 그 자식으로 카메라를 넣어 z값을 띄워줍니다.
그리고 cam의 y값을 회전시키면 카메라의 z값이 반지름이 돼서 어떤 오브젝트를 일정한 거리로 바라보게 할 것입니다.
회전은 이런식으로 코드로 구현할 거고, 바라보는 것은 Cinemachine을 이용할 예정입니다.

1. Cinemachine(시네머신)

Cinemachine은 플레이어를 따라 이동하는 카메라나, 카메라가 플레이어를 바라보는 기능이 필요할 때 많이 사용합니다.
Dolly Camera, Mixing Camera 등 종류가 다양한데, 저는 그 중에서 가장 기본적인 Cinemachine Camera를 이용하였습니다.
카메라를 넣기 전에 Sphere 안에 빈 오브젝트로 Cam을 만들어주고 그 자식으로 Cinemachine Camera를 넣어주세요.


Cinemachine Camera를 생성하면 이런 컴포넌트가 있을 겁니다.
제가 사용할 것은 Tracking Target / Position, Rotation Control을 사용할 겁니다.

Tracking Target : 따라갈 목표
Position Control : 위치 조작
Rotation Control : 방향 조작

여기서 Position Control의 경우 직접 카메라를 움직이지는 않을 것이라 None으로 설정해주고, Rotation Control을 Rotation Composer로 설정해 주세요.
Tracking Target의 경우 바라볼 오브젝트를 넣으면 됩니다. 저는 Sphere를 넣어 행성을 바라보게끔 설정하였습니다.

Rotation Control을 설정할 경우 아래에 이런 컴포넌트가 생길겁니다. 여기서 카메라의 로테이션 오프셋을 설정할 수 있습니다.
저는 약간 아래로 보는 것을 설정하고 싶어서 Screen Position을 0.3으로 설정하였습니다.
이렇게 설정을 하고 Cam을 움직여 보면 오브젝트를 바라보며 움직일 겁니다.

2. 스크립트 구현

전 포스트에서 만든 Input System을 이용하여 드래그하면 그 방향으로 회전하게끔 만들어 보겠습니다.
(전 포스트에 없던 새로 추가된 것들은 *표시를 하였습니다.)

using System;
using UnityEngine;
using UnityEngine.InputSystem;

namespace JMT.InputSystem
{
    [CreateAssetMenu(menuName = "SO/Input/InputSO")]
    public class CameraInputSO : ScriptableObject, Controls.IScreenTouchActions
    {
        *public event Action OnRotateStartEvent;
        *public event Action OnRotateEndEvent;
        public event Action OnZoomStartEvent;
        public event Action OnZoomEndEvent;

        private Controls controls;

        private void OnEnable()
        {
            if (controls == null)
            {
                controls = new Controls();
                controls.ScreenTouch.SetCallbacks(this);
                controls.ScreenTouch.Enable();
            }
        }

        private void OnDisable()
        {
            controls.ScreenTouch.Disable();
        }

        public Vector2 GetPrimaryPosition() => controls.ScreenTouch.PrimaryTouch.ReadValue<Vector2>();
        public Vector2 GetSecondaryPosition() => controls.ScreenTouch.SecondaryTouch.ReadValue<Vector2>();

        public void OnSecondaryTouchContact(InputAction.CallbackContext context)
        {
            switch(context.phase)
            {
                case InputActionPhase.Started:
                    *OnRotateEndEvent?.Invoke();
                    OnZoomStartEvent?.Invoke();
                    break;
                case InputActionPhase.Canceled:
                    OnZoomEndEvent?.Invoke();
                    break;
            }
        }

        public void OnPrimaryTouchContact(InputAction.CallbackContext context)
        {
            *switch (context.phase)
            {
                case InputActionPhase.Started:
                    OnRotateStartEvent?.Invoke();
                    break;
                case InputActionPhase.Canceled:
                    OnRotateEndEvent?.Invoke();
                    break;
            }
        }

        public void OnPrimaryTouch(InputAction.CallbackContext context)
        {
        }

        public void OnSecondaryTouch(InputAction.CallbackContext context)
        {
        }
    }
}

회전 시작 액션과 종료 액션을 추가하였고, 첫 터치가 눌렸을 때 시작 액션을 Invoke 해주었습니다.
종료 액션은 첫터치가 취소되었을 때 Invoke를 하여 회전이 종료되도록 만들었습니다.
이제 실질적으로 드래그를 통해 회전을 구현해 보도록 하겠습니다.

using JMT.InputSystem;
using System.Collections;
using UnityEngine;
using DG.Tweening;

namespace JMT.CameraSystem
{
    public class CameraInput : MonoBehaviour
    {
        [SerializeField] private float camSpeed = 4f;
        [SerializeField] private float rotateSpeed = 4f;
        [SerializeField] private CameraInputSO inputSO;
        [SerializeField] private Transform camParentTrm;
        private Transform camTransform;
        private Coroutine zoomCoroutine;
        private Coroutine rotateCoroutine;

        private void Awake()
        {
            camTransform = camParentTrm.GetChild(0);
            inputSO.OnZoomStartEvent += HandleZoomStartEvent;
            inputSO.OnZoomEndEvent += HandleZoomEndEvent;
            *inputSO.OnRotateStartEvent += HandleRotateStartEvent;
            *inputSO.OnRotateEndEvent += HandleRotateEndEvent;
        }

        private void HandleZoomStartEvent()
        {
            zoomCoroutine = StartCoroutine(ZoomDetection());
        }

        private void HandleZoomEndEvent()
        {
            StopCoroutine(zoomCoroutine);
        }

        private IEnumerator ZoomDetection()
        {
            float prevDistance = Vector2.Distance(inputSO.GetPrimaryPosition(), inputSO.GetSecondaryPosition());
            while (true)
            {
                float distance = Vector2.Distance(inputSO.GetPrimaryPosition(), inputSO.GetSecondaryPosition());
                float deltaDistance = distance - prevDistance;

                Vector3 targetPos = camTransform.localPosition;
                targetPos.z -= deltaDistance * camSpeed;
                targetPos.z = Mathf.Clamp(targetPos.z, 0.3f, 3f);

                camTransform.localPosition = new Vector3(camTransform.localPosition.x, camTransform.localPosition.y,
                                            Mathf.Lerp(camTransform.localPosition.z, targetPos.z, Time.deltaTime * camSpeed));

                prevDistance = distance;
                yield return null;
            }
        }
        *private void HandleRotateStartEvent()
        {
            rotateCoroutine = StartCoroutine(RotateCoroutine());
        }
		
        private IEnumerator RotateCoroutine()
        {
            float prevX = inputSO.GetPrimaryPosition().x;
            float prevY = inputSO.GetPrimaryPosition().y;
            Vector3 currentRotation = camParentTrm.eulerAngles;

            while (true)
            {
                float currentX = inputSO.GetPrimaryPosition().x;
                float xValue = prevX - currentX;
                float currentY = inputSO.GetPrimaryPosition().y;
                float yValue = prevY - currentY;

                currentRotation.y -= xValue * Time.deltaTime * rotateSpeed;
                currentRotation.x -= yValue * Time.deltaTime * rotateSpeed;

                if (currentRotation.x > 180f) currentRotation.x -= 360f;
                currentRotation.x = Mathf.Clamp(currentRotation.x, -60f, 60f);

                camParentTrm.rotation = Quaternion.Euler(currentRotation);

                prevX = currentX;
                prevY = currentY;
                yield return null;
            }
        }
    }
}

회전은 처음 x좌표와 지금 x좌표를 비교하여 회전하고, y좌표 역시 이렇게 회전합니다.
중간에 보다보면 아래와 같이 조금 머리가 아픈 코드가 있는데요.

currentRotation.y -= xValue * Time.deltaTime * rotateSpeed;
currentRotation.x -= yValue * rotateSpeed * Time.deltaTime;

왜 value는 x인데, Rotation.y에 넣는건지 이해가 잘 안되는 분들이 있을 겁니다.

value는 현재 터치의 value값을 의미합니다.

하지만 회전의 경우 x값을 변경하면 x축을 고정으로 y축이 바뀌게 됩니다. y값 역시 y축을 고정으로 x축이 바뀌게 되고요.
따라서 Rotation.y값에 xValue를, Rotation.x값에 yValue를 넣어주는 것입니다.

x의 값이 180 초과일 경우에 -360을 해주는 이유는 오일러각 때문입니다.
오일러각의 경우 0~360도이지만, 저희는 -180~180도를 다뤄야 하기 때문에 180이 넘어가면 -360을 빼줘서 -180으로 만들어 주는 것이죠.

Mathf.Clamp는 값이 조금 넘어가면 카메라가 뒤집히는 문제가 생겨 위와 아래의 회전을 제한해 주었습니다.

이렇게 작성하고 플레이를 해 보면 y값 회전과 x값 회전을 할 수 있습니다.

profile
경기게임마이스터고 4기

0개의 댓글