본문 바로가기

유니티/자동차

유니티 모바일 레이싱휠 조이스틱 만들기

모바일 레이싱 조이스틱

레이싱 게임은 모바일 환경에서 위와 같은 조이스틱을 마련해두고 있습니다. 페달은 유니티에서 기본적으로 제공하는 UIButton을 사용하면 쉽게 구현이 가능하지만 스티어링 휠처럼 원 형태의 입력방식은 기본 제공하지 않기 때문에 직접 구현하여야 합니다. 일반적으로 알려진 스티어링을 조작하는 방법은 잡고 돌리는 것으로 원 운동(회전)임을 알 수 있고, 2차원 평면에서의 회전은 아크탄젠트를 활용하면 어렵지 않게 다룰 수 있습니다.

 

목차

1. UI 디자인

2. 터치 좌표 가져오기

3. 터치 좌표로부터 회전 값 구하기

4. 스티어링 휠 완성

 

 

1. UI 디자인

Canvas를 추가하고 UI 오브젝트를 위와 같은 계층으로 구성합니다.

SteeringWheel은 터치 좌표를 입력 받기위해 필요합니다.

wheelImg는 스티어링휠 이미지를 표현하기 위해 필요하며, 계산된 회전 값이 세팅되는 오브젝트입니다. 

최종적으로 위와 같은 모습입니다.

 

 

2. 터치 좌표 가져오기

 

SteeringWheel 스크립트를 생성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
 
public class SteeringWheel : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IDragHandler
{
    public RectTransform rect;
    public RectTransform wheelImg;
 
    public void OnPointerDown(PointerEventData e)
    {
        RectTransformUtility.ScreenPointToLocalPointInRectangle(rect, e.position, nullout Vector2 localPos);
    }
 
    public void OnDrag(PointerEventData e)
    {
        RectTransformUtility.ScreenPointToLocalPointInRectangle(rect, e.position, nullout Vector2 localPos);
    }
 
    public void OnPointerUp(PointerEventData e)
    {
 
    }
}
cs

터치 및 터치 좌표를 얻어오기 위해 EventSystems의 IPointerDownHandler, IPointerUpHandler, IDragHandler를 상속 받고 구현부를 작성해줍니다.

 

RectTransformUtility.ScreenPointToLocalPointInRectangle 는 커서의 포지션(스크린 좌표)을 RectTrasform 로컬 포지션으로 변환할 수 있게 도와주는 함수입니다.

 

함수의 도움으로 스티어링 휠의 어느 부분을 터치하였는지 손쉽게 구할 수 있게 되었습니다.

 

마지막으로

SteeringWheel 스크립트를 SteeringWheel 오브젝트에 추가하고

Rect에 SteeringWheel 오브젝트를,

Wheel Img에 wheelImg 오브젝트를 끌어다 놓아 캐싱을 합니다. 

 

 

3. 터치 좌표로부터 회전 값 구하기

 

코드를 작성하기 앞서 스티어링 휠이 어떻게 작동하는지 알아야 할 필요가 있습니다.

 

* 시계방향 또는 반시계방향으로 회전할 수 있습니다.

* 부드럽게 회전하여야 합니다.

* 360° 이상 회전할 수 있습니다.

* 최대로 회전할 수 있는 각도가 존재합니다. 

 

SteeringWheel 스크립트 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
 
public class SteeringWheel : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IDragHandler
{
    public RectTransform rect;
    public RectTransform wheelImg;
 
    float prevTheta = 0f;
    float accTheta = 0f;
    float startPivot = Mathf.PI / 2;
 
    readonly float degreeRate = 1 / 1080f;
    readonly float minDegreeLock = Mathf.PI * -6;
    readonly float maxDegreeLock = Mathf.PI * 6;
 
    bool isHold;
 
    public void OnPointerDown(PointerEventData e)
    {
        isHold = false;
 
        RectTransformUtility.ScreenPointToLocalPointInRectangle(rect, e.position, nullout Vector2 localPos);
        startPivot = Mathf.Atan2(localPos.y, localPos.x);
        GetDegree(localPos);
 
        isHold = true;
    }
 
    public void OnDrag(PointerEventData e)
    {
        isHold = true;
        startPivot = 0f;
 
        RectTransformUtility.ScreenPointToLocalPointInRectangle(rect, e.position, nullout Vector2 localPos);
        float degree = GetDegree(localPos);
 
        wheelImg.localRotation = Quaternion.Euler(0f, 0f, degree);
    }
 
    public void OnPointerUp(PointerEventData e)
    {
        isHold = false;
        startPivot = 0f;
    }
 
    float GetDegree(Vector2 cursor)
    {
        float x = cursor.x;
        float y = cursor.y;
        float theta = Mathf.Atan2(y, x);
 
        // 휠 터치를 떼었다가 다시 붙이면 pivot이 바뀌므로 pivot 교체해야함
        if (!isHold)
        {
            prevTheta = Mathf.Abs(startPivot);
        }
 
        float diff = 0f;
        if (theta > 0// 부호가 양수일때 (1,2사분면)
        {
            diff = Mathf.Abs(theta) - prevTheta;
        }
        else if (theta < 0// 부호가 음수일때 (3,4사분면)
        {
            diff = prevTheta - Mathf.Abs(theta);
        }
 
        if (diff > 0 || diff < 0)
        {
            accTheta += diff;
        }
        else
        {
            // 가만히
        }
 
        prevTheta = Mathf.Abs(theta);
        accTheta = Mathf.Clamp(accTheta, minDegreeLock, maxDegreeLock); // 최대휠각
        float degree = accTheta * Mathf.Rad2Deg;
 
        return degree;
    }
}
 
cs

 

터치를 통해 입력받은 커서좌표(스크린좌표)값을 Atan2 함수에 넣으면 각(Theta)을 구할 수 있습니다.

 

드래그할 때마다(OnDrag) 커서좌표를 입력받아 위 방법으로 각을 구하고 이전에 계산된 각을 빼주면 각의 변화량을 측정할 수 있습니다.

 

예를 들어 이전에 계산된 각이 30도였고 새로 갱신된 각이 40°일 때

40° - 30° = 10° 이므로 변화량은 10°인 것을 확인할 수 있습니다. 

 

이렇게 얻은 값을 누적(accTheta)하고 그 값을 휠 이미지의 rotation에 적용하면 부드럽게 회전하는 스티어링 휠이 될 것입니다. 

 

문제점 해결 1.

1,2 사분면은 양수 / 3,4 사분면은 음수

Atan2 (아크탄젠트) 함수는 반원의 각도 밖에 구하지 못하며, 심지어 3,4사분면은 음수로 표현됩니다! 

 

휠을 드래그할 때 2사분면에서 3사분면으로 넘어가게 되면 Atan2로 계산된 결과가 +180°에서 -180°으로 변하는 것을 확인하실 수 있을 것입니다. 뭔가 일반적인 상식대로라면 +180°다음에는 +181°이 되어야 할텐데 말입니다.

(디버깅시, Atan2 결과는 라디안 값이기 때문에 180°이 아닌 3.14로 표현될 것이며 우리가 보는 친숙한 도(°) 단위는 결과 값에 Mathf.Rad2Deg를 곱해주면 확인할 수 있습니다.) 

 

해결 방법은 Atan2의 계산 결과가 음수 일 때, 변화량 계산식을 뒤집으면 됩니다.

 

새로 갱신된 각 - 이전 계산된각 = 변화량

이전 계산된각 - 새로 갱신된각 =  변화량

(62줄)

 

문제점 해결2.

스티어링 휠을 잡고 회전하면 문제 없이 작동하나, 터치를 떼고 다시 터치를 하여 회전을 하면 점프하듯이 회전하는 모습을 확인할 수 있습니다.

 

이는 계산된 각을 누적하는 방식으로 스티어링 휠을 구현하였기 때문에 발생하는 문제입니다. 

휠을 회전하다가 터치를 떼었을 때 마지막으로 계산된 값이 30도였고

다시 터치를 하였을 때 계산된 값이 135도일 때

 

135° - 30° = 105° 변화량의 결과가 나오게 되어

스티어링 휠이 한번에 105°를 회전해버리는 이상한 현상이 나타난 것입니다.

 

실제로 스티어링휠이 이렇게 작동하지 않다는 것을 우리는 알 수 있습니다.

자동차 운전석에 앉아 스티어링휠 윗부분을 잡은 다음, 손을 떼고 아랫 부분을 잡았을 때 차가 한번에 180° 회전하는 경우는 보신 적이 없을 겁니다. 보셨다면 적어도 우리 우주는 아닐 겁니다.

 

아무튼.. 실제로 우리가 운전을 할 때 스티어링을 잡는 위치는 수시로 변합니다.

손의 위치는 중요하지 않습니다. 어디로든 돌아가기만 하면 되니까요!  

어디에서 스티어링 휠을 잡더라도 변화량 값을 누적하는데 영향이 없어야 합니다.

 

prevTheta의 역할이 각 변화량의 시작점이므로, 터치를 떼고 다시 터치를 할 때만 시작점을 갱신시켜주면 됩니다. (55줄)

 

위의 예시처럼 30°에서 터치를 떼고 135° 지점에 터치를 하였다고 가정하면

prevTheta를 30°가 아닌, 135°로 세팅해주면 한번에 점프하듯이 회전하지 않고 정상적으로 회전할 것입니다.

 

4. 스티어링 휠 완성

 

마지막으로 스티어링휠이 최대로 회전할 수 있는 각도를 지정합니다.

차량마다 다르지만 3바퀴라고 가정하면 360° * 3 = 1080° 입니다.

 

(16줄)

minDegreeLock, MaxDegreeLock이 Mathf.PI * ± 6인 이유는 PI는 180°이므로 6번 곱해야 1080°가 되기 때문입니다.

 

(81줄)

GetDegree 함수의 하단에 Mathf.Clamp를 이용하여 최대 회전 각도를 넘어서 회전할 수 없게 합니다.

 

스티어링 휠 완성