소소한 개발 공부

[Unity] 2D 핀치 줌 Pinch Zoom 구현 + 화면 끌기(패닝 Panning) 본문

개발/Unity

[Unity] 2D 핀치 줌 Pinch Zoom 구현 + 화면 끌기(패닝 Panning)

이내내 2021. 5. 19. 04:39
📝참고 : [RectTransform 이해하기: Pan & Pinch Zoom 구현 - PlaneK, 흔한 개발자의 메모장, 2020. 8. 18.] 을 참고해서
작성했습니다.

 

줌인 줌아웃

2D 탑다운 형식 게임의 터치 줌인-줌아웃을 구현하려고 한다.

1. Camera 는 Projection - Orthographic을 사용하며 줌인=OrthographicSize 감소 / 줌아웃= OrthographicSize 증가

2. 입력 방식은 터치.

3. 줌인-줌아웃의 중앙은 터치 위치를 기준

ex) 클래시오브클랜, 쿠키런 킹덤에서 줌인-줌아웃을 할 때 터치하는 곳을 중심으로 줌인-줌아웃

줌인 - 줌아웃

패닝(Panning)

화면이 줌인 되어 있을 때 화면을 끌어 당기는 기능을 추가할 것이다. 스크린을 터치로 탐색하는 것.

1. 터치를 오른쪽 -> 왼쪽으로 끌었을 때 : 화면이 왼쪽 -> 오른쪽으로 이동

2. 터치를 왼쪽 -> 오른쪽으로 끌었을 때 : 화면이 오른쪽 -> 왼쪽으로 이동

왼쪽에서 오른쪽으로 패닝Panning

 

우선 줌인-줌아웃을 구현할 스크립트(Zoom Controller)를 만들어서 객체에 할당하고

지금 줌인-줌아웃이 제대로 되고 있는지 테스트할 sprite 에 이미지를 붙여서 BG로 만들어놨다. 줌 테스트를 할 때 이미지의 특정한 곳을 터치해 줌인-줌아웃을 테스트할 것이다.

 

카메라는 Projection - Orthographic으로 지정해놓고 Size는 5로 해놨다. (Size는 임의로 변경가능)

 

전체코드

더보기
using UnityEngine;
using UnityEngine.EventSystems;

public class ZoomController : MonoBehaviour
{
    [Header("Zoom")]
    public Camera cam;
    public float slideSpeed;

    private float zoomSpeed = 1.5f;
    private float zoomInMax = 1f;
    private float zoomOutMax = 5f;

    private void Update() 
    {
        ScreenSlide();
        ZoomInOut();
    }

    private void ScreenSlide()
    {
        if (Input.touchCount == 1 && (Input.GetTouch(0).phase == TouchPhase.Began || Input.GetTouch(0).phase == TouchPhase.Moved))
        {   
            if (EventSystem.current.IsPointerOverGameObject())
            {
                return;
            }
            var deltaPos = Input.GetTouch(0).deltaPosition;

            cam.transform.position -= (Vector3)(deltaPos) * slideSpeed * Time.deltaTime;

            // /* clamp */
            var clampX = (zoomOutMax * 2  * cam.aspect) / 2 - (cam.orthographicSize * 2 * cam.aspect) / 2;
            var clampY = zoomOutMax - cam.orthographicSize;
            var clampedPosX = Mathf.Clamp(cam.transform.position.x, -clampX, clampX);
            var clampedPosY = Mathf.Clamp(cam.transform.position.y, -clampY, clampY);
            cam.transform.position = new Vector3(clampedPosX, clampedPosY, cam.transform.position.z);
        }
    }

    private void ZoomInOut()
    {
        if (Input.touchCount == 2)
        {   // 줌인 줌아웃
            /* get zoomAmount */
            var curTouchAPos = Input.GetTouch(0).position;  // 현재 터치 중인 터치 1번 손가락
            var curTouchBPos = Input.GetTouch(1).position;  // 현재 터치 중인 터치 2번 손가락
            var prevTouchAPos = curTouchAPos - Input.GetTouch(0).deltaPosition; // 이전 프레임 1번 손가락
            var prevTouchBPos = curTouchBPos - Input.GetTouch(1).deltaPosition; // 이전 프레임 2번 손가락
            var deltaDistance =
            Vector2.Distance(Normalize(curTouchAPos), Normalize(curTouchBPos))
            - Vector2.Distance(Normalize(prevTouchAPos), Normalize(prevTouchBPos));

            float curSize = cam.orthographicSize;   // 클수록 줌아웃
            var zoomAmount = deltaDistance * curSize * zoomSpeed;

            /* clamp & zoom */
            curSize -= zoomAmount;
            if (curSize < zoomInMax)
            {
                curSize = zoomInMax;
                zoomAmount = 0f;
            }
            if (zoomOutMax < curSize)
            {
                curSize = zoomOutMax;
                zoomAmount = 0f;
            }
            cam.orthographicSize = curSize;

            /* apply offset */
            // offset is a value against movement caused by scale up & down
            var pivotPos = cam.transform.position;
            var fromCenterToInputPos = new Vector3(                         // 터치 입력을 카메라 중심으로 보정
                Input.mousePosition.x - Screen.width * 0.5f, 
                Input.mousePosition.y - Screen.height * 0.5f, 0f);
            var fromPivotToInputPos = fromCenterToInputPos - pivotPos;
            var offsetX = (fromPivotToInputPos.x / curSize) * zoomAmount * 0.01f;
            var offsetY = (fromPivotToInputPos.y / curSize) * zoomAmount * 0.01f;
            cam.transform.position += new Vector3(offsetX, offsetY, 0f);

            // /* clamp */
            var clampX = (zoomOutMax * 2  * cam.aspect) / 2 - (cam.orthographicSize * 2 * cam.aspect) / 2;
            var clampY = zoomOutMax - cam.orthographicSize;
            var clampedPosX = Mathf.Clamp(cam.transform.position.x, -clampX, clampX);
            var clampedPosY = Mathf.Clamp(cam.transform.position.y, -clampY, clampY);
            cam.transform.position = new Vector3(clampedPosX, clampedPosY, cam.transform.position.z);
        }
    }

    private Vector2 Normalize(Vector2 position)
    {// 해상도 표준화
        var normalizedPos = new Vector2(
            (position.x - Screen.width * 0.5f) / (Screen.width * 0.5f),
            (position.y - Screen.height * 0.5f) / (Screen.height * 0.5f));
        return normalizedPos;
    }
}

 

코드를 하나하나씩 보자면 다음과 같다.

 

- 사용 변수

public Camera cam;		// Scene 상의 Main Camera

// 패닝 용 변수
public float slideSpeed;	// 0.4f 로 해서 테스트를 한다.

// 줌인 줌아웃 용 변수
private float zoomSpeed = 1.5f;
private float zoomInMax = 1f;
private float zoomOutMax = 5f;

 

Zoom In / Zoom Out

- ZoomInOut _ OrthographicSize로 카메라 사이즈 줄이기

// 줌인 줌아웃
/* get zoomAmount */
var curTouchAPos = Input.GetTouch(0).position;  // 현재 터치 중인 터치 1번 손가락
var curTouchBPos = Input.GetTouch(1).position;  // 현재 터치 중인 터치 2번 손가락
var prevTouchAPos = curTouchAPos - Input.GetTouch(0).deltaPosition; // 이전 프레임 1번 손가락
var prevTouchBPos = curTouchBPos - Input.GetTouch(1).deltaPosition; // 이전 프레임 2번 손가락
var deltaDistance =
	Vector2.Distance(Normalize(curTouchAPos), Normalize(curTouchBPos))
    - Vector2.Distance(Normalize(prevTouchAPos), Normalize(prevTouchBPos));

float curSize = cam.orthographicSize;   // 클수록 줌아웃
var zoomAmount = deltaDistance * curSize * zoomSpeed;

화면 줌인 줌아웃을 위한 터치는 Update문에 둬서 터치가 있을 때 마다 작동할 수 있게 한다.

Input.GetTouch(x).deltaPosition은 x번째 터치의 이전 프레임에서의 터치 위치와 이번 프레임에서 터치위치 차이.

=> ex) 이전 프레임 터치 위치 (0,3) / 이번 프레임 터치 위치 (1,4) / deltaPosition (1, 1)

 

deltaDistance 는 (현재 두 손가락 사이 거리) - (이전 두 손가락 사이 거리)로 양의 값이면 줌인 / 음의 값이면 줌아웃이 된다.

 

zoomAmount는 이번 프레임에서 줌 할 값이다. 터치 속도에 따라 줌 속도가 달라지고 터치 방향에 따라 줌인-줌아웃이 달라지므로 연결해서 계산한다. zoomSpeed는 보정값

 

Normailze 함수

private Vector2 Normalize(Vector2 position)
{// 해상도 표준화
	var normalizedPos = new Vector2(
        (position.x - Screen.width * 0.5f) / (Screen.width * 0.5f),
        (position.y - Screen.height * 0.5f) / (Screen.height * 0.5f));
    return normalizedPos;
}

 

zoomAmount 를 구했으니 zoomAmount 만큼 현재에서 줌한다.

/* clamp & zoom */
curSize -= zoomAmount;
if (curSize < zoomInMax)
{
	curSize = zoomInMax;
    	zoomAmount = 0f;
}
if (zoomOutMax < curSize)
{
	curSize = zoomOutMax;
    	zoomAmount = 0f;
}
cam.orthographicSize = curSize;

줌인 가능한 최대 허용치는 OrthographicSize = 1f 이다.

줌아웃 가능한 최대 허용치는 OrthographicSize = 5f이다.(내 게임 기준)

 

OrthographicSize는 값이 작을 수록 객체를 확대하고(=줌인)

값이 클수록 객체를  축소한다.(=줌아웃)

 

여기까지만 하면 어디를 터치해도 줌인-줌아웃의 기준이  화면의 중앙이 된다. 그래서 터치 위치에 따라 줌 중앙을 바꿔줄 offset을 계산해야한다.

/* apply offset */
// offset is a value against movement caused by scale up & down
var pivotPos = cam.transform.position;
var fromCenterToInputPos = new Vector3(                         // 터치 입력을 카메라 중심으로 보정
				Input.mousePosition.x - Screen.width * 0.5f, 
                		Input.mousePosition.y - Screen.height * 0.5f, 0f);
var fromPivotToInputPos = fromCenterToInputPos - pivotPos;
var offsetX = (fromPivotToInputPos.x / curSize) * zoomAmount * 0.01f;
var offsetY = (fromPivotToInputPos.y / curSize) * zoomAmount * 0.01f;
cam.transform.position += new Vector3(offsetX, offsetY, 0f);

offset을 계산하기 전에 줌 중앙은 camera의 위치이다. (0,0,0)

 

pivotPos 는 camera의 위치.

fromCenterToInputPos 는 좌하단이 (0,0) 우상단이 해상도값인 터치Touch를 중앙이 (0,0)인 로컬 값으로 바꾼 변수.

fromPivotToInputPos 는 fromCenterToInputPos에서 현재 카메라 위치값을 뺀 것으로 현재 카메라 위치 -> 터치 위치를 가리키는 벡터(방향, 크기를 모두 가짐)값.

offsetX/Y 는 카메라 위치에서 터치 위치로 이동하려는 벡터값을 보정한다.(OrthographicSize와 줌 속도에 맞게 카메라가 이동을 해야함)

curSize(OrthographicSize)에 반비례하며, 속도는 zoomAmount를 0.01배(보정값, 테스트하며 수정 가능) 하여 카메라의 현재 위치(transform.position)에 더한다.

 

결론적으로 계속 카메라의 중심을 움직임으로써 줌인 줌아웃을 실행하는 것.

 

/* clamp */
var clampX = (zoomOutMax * 2  * cam.aspect) / 2 - (cam.orthographicSize * 2 * cam.aspect) / 2;
var clampY = zoomOutMax - cam.orthographicSize;
var clampedPosX = Mathf.Clamp(cam.transform.position.x, -clampX, clampX);
var clampedPosY = Mathf.Clamp(cam.transform.position.y, -clampY, clampY);
cam.transform.position = new Vector3(clampedPosX, clampedPosY, cam.transform.position.z);

경계를 지정해준다.

카메라가 특정 x,y값을 넘어가면 보이지 말아야할 것도 보여주게 되므로 경계값이 필요하다. 그런데 orthographicSize에 따라 카메라가 이동할 수 있는 최댓값, 최솟값이 바뀌므로 변수로 작성한다.

 

카메라 화면 넓이는 다음과 같다.

height = cam.OrthographicSize * 2

width = height * cam.aspect

cam.aspect = Screen.width / Screen.height

 

따라서 카메라가 이동할 수 있는 최댓값은

MaxHeight/2 - curHeight/2

MaxWidth/2 - curWidth/2 가 된다.

 

현재 ortho값이 zoomOutMax와 같다면 카메라가 이동하지 않는다. 반대로 ortho값이 1이고 zoomOutMax가 5일때 최대로 위치할 수 있는 y값이 4가 된다.

 

최솟값은 부호만 다르면 된다.

그리고 현재값을 Mathf.Clamp를 적용해 값이 튀는 걸 막는다. 

 

Panning

패닝은 더 간단하다.

private void ScreenSlide()
{    
	if (Input.touchCount == 1 && (Input.GetTouch(0).phase == TouchPhase.Began || Input.GetTouch(0).phase == TouchPhase.Moved))
    	{   
    		if (EventSystem.current.IsPointerOverGameObject())
        	{
           		return;
        	}
        	var deltaPos = Input.GetTouch(0).deltaPosition;

        	cam.transform.position -= (Vector3)(deltaPos) * slideSpeed * Time.deltaTime;

			// /* clamp */
        	var clampX = (zoomOutMax * 2  * cam.aspect) / 2 - (cam.orthographicSize * 2 * cam.aspect) / 2;
        	var clampY = zoomOutMax - cam.orthographicSize;
        	var clampedPosX = Mathf.Clamp(cam.transform.position.x, -clampX, clampX);
        	var clampedPosY = Mathf.Clamp(cam.transform.position.y, -clampY, clampY);
        	cam.transform.position = new Vector3(clampedPosX, clampedPosY, cam.transform.position.z);
    	}
}

터치가 시작했거나(TouchPhase.Began) 터치 중(TouchPhase.Moved)일 때 패닝하고 있음을 인지한다.

 

EventSystem.current.IsPointerOverGameObject()는 터치했을 때 해당 위치에 UI 혹은 EventSystem 작용을 하는 객체가 있는 지 검사한다. 만약 객체가 있다면 패닝을 하지 않고 함수를 탈출한다. 그냥 버튼을 누르고 있을 뿐인데 화면이 움직이면 안되기 때문에 넣어놓는다.

 

deltaPosition은 터치 위치의 변화량이다. 방향(부호)과 크기를 모두 가졌기 때문에  그대로 transform.position을 변화시키는 식에 넣는다. 그리고 slideSpeed를 넣고 찬찬히 움직일 수 있도록 보정하고 Time.deltaTime을 넣어서 추가 보정한다.(성능에 따라 한 프레임당 걸리는 시간이 기기별로 다르기 때문)

 

또한 여기서도 패닝하느라 보여주고자 하는 이미지를 벗어나게 되면 안되므로 Clamp를 넣는다. (이것은 위의 설명 참고)

 

실행 시 줌인-줌아웃이 원활하게 이루어지는 걸 볼 수 있다.

터치 테스트는 안드로이드 어플 Remote5를 이용했다.