본문 바로가기

Unity/로직 설계

[Unity] 책 넘기는 효과 구현하기 (Page Curl) - 3

어찌어찌 완성은 했다만...

그래픽이 구린 건 잠시 접어두자.

 

아직 부족한 점이 많다.

 

  • 펀치 마스크를 활용하며 추가적인 마스크를 쓰지 못하게 된 점
    • 이때문에 Block 이미지로 앞을 가려줘야 하는 부분이라던지.
  • DOTween과 Update의 충돌
  • 부자연스러운 그림자 위치
  • 맞물리지 않는 BackPage와 Mask의 관계

등등. 하나씩 개선을 해보자.


DOTween과 Update의 충돌

DOTween으로 인한 이동과 Update에서의 계산 순서가 정해져 있지 않은지, 책 페이지를 넘길 때마다 BackPage가 잠깐 보이는 현상이나, 맨 윗 줄이 딱 맞물리지 않는 현상이 발생한다.

 

 

예를 들면 위 사진같은 상황이 발생한다. 이동한 것보다 더 돌아가서, 정확히는 덜 줄어들어서 위처럼 빈칸이 남아버린다.

 

이를 고치기 위해선 DOTween 대신 Coroutine을 사용해 계산 시점을 맞춰주는 방법 등을 떠올릴 수 있다.

 

 

Unity3D Simple Page Curl Effect

Unity3D, 2D, Page Curl Effect, Page Flip Effect, UI, Canvas, 페이지 넘김 효과, 페이지 넘기기 page-curl effect with 2d mask tweak.유니티 UI의 Mask기능을 이용한 간단한 페이지 넘기기 예제입니다. 소스코드입니다. 의

gnupart.tistory.com

 

하지만 위 블로그에서 LateUpdate를 사용한 걸 보고, 우선 이것부터 적용해보기로 했는데...

 

 

여전히 첫 장이 이상하긴 하지만, 이후 페이지들은 아무 문제 없음이 확인되었다.

 

이로써 하나 해결.


마스크 구조 변경

펀치 마스크를 사용하니 여러 문제점이 발생했다. Maskable 옵션을 지켜주려면, 이를 쓰지 않는 새로운 방법이 필요했다.

 

이에 Mask 오브젝트를 뒤집어 왼쪽을, 크게 감싸는 형태로 변경했다.

또한 몇 가지 변화를 함께 줬다.


UI 투명 마스크 생성

기존엔 Image 컴포넌트에서 투명도를 0으로 주면 마스킹이 안 되는 현상이 있었다.

이는 Mask 컴포넌트의 Show Mask Graphic을 끄면 해결됨을 알게 되었다.

 

이 옵션을 해제하면 투명 마스크가 생성된다.


이미지 피벗 변경

UI 피벗을 바꿔도 Scene에서 움직일 때 아이콘이 중앙에 뜨기에, 마치 피벗이 적용 안 되는 줄 알았었다.

그렇기에 Pivot 오브젝트를 따로 두었던 건데, 다시 테스트해보니 게임 씬에선 제대로 적용됐다.

 

이에 복잡하게 Pivot 오브젝트를 둘 필요없이, 이미지 오브젝트 하나만 두며 계층 구조가 간결해졌다.


합체

그렇게 완성된 구조는 아래와 같다.

 

Page는 빈 오브젝트다

 

Blocker 없이도, 그리고 더 자연스럽게 책 넘김 효과가 구현되었다.


그림자 모양 변경

이제 디테일을 더해, 그림자의 모양을 바꿔주자.

스크립트를 이용해 Gradient 클래스를 적용하는 법도 있지만, 그냥 클립 스튜디오로 그렸다.

 

클립 스튜디오로 간단하게 만든 그라데이션 이미지. 마음껏 써도 된다.

 

그림자만 바꿔줬을 뿐인데 더 책 페이지 같아졌다. (비록 첫 장은... 지못미지만)

 

이 정도면 내가 원하던 만큼은 충분히 나온 것 같다.


DOTween 손보기

내가 원하던 건 책 페이지가 포물선으로 넘어가는 것이었으나, 현재는 너무 뚝뚝 끊기는 모양이 난다.

가장 먼저, SetEase를 개별적으로 적용한다.

 

// 페이지를 움직인다.
private void MoveBackPage(int i)
{
    // 틀어질 경우를 대비해, 시작 위치로 이동시킨다.
    backPage[i].transform.position = firstBackPagePosition;

    // 위치를 포물선으로 이동시킨다. (개선 필요)
    DOTween.Sequence()
        .Append(backPage[i].transform.DOMoveX(firstBackPagePosition.x - 300f, 1f).SetEase(Ease.Linear))
        .Join(backPage[i].transform.DOMoveY(firstBackPagePosition.y + 150f, 1f).SetEase(Ease.OutCubic))
        .Append(backPage[i].transform.DOMoveX(firstBackPagePosition.x - 600f, 1f).SetEase(Ease.Linear))
        .Join(backPage[i].transform.DOMoveY(firstBackPagePosition.y, 1f).SetEase(Ease.InCubic))
        // 끝나면 Curling 종료, 페이지 번호 증가, 다음 장을 제일 위로 올리기
        .OnComplete(() => { 
            isCurling = false;
            pageNumber++;
            pages[pageNumber].SetAsLastSibling();
        });
}

 

Y 변화량은 중간까진 느리다가 빠르게, 중간부턴 빠르다가 느려져야 하니 각각 OutCubic과 InCubic을 주었다. X 값은 일정하게 움직이도록 Linear를 적용시켰다.

 

그 다음은 넘어가는 속도를 쉽게 조정할 수 있게, 변수로 만드는 것이다. 이는 SerializeField로 구현해 쉽게 테스트 가능하게 했다.

// DOTween 애니메이션 관련
public float flipTime = 1f;

// 페이지를 움직인다.
private void MoveBackPage(int i)
{
    // 틀어질 경우를 대비해, 시작 위치로 이동시킨다.
    backPage[i].transform.position = firstBackPagePosition;

    // 위치를 포물선으로 이동시킨다. (개선 필요)
    DOTween.Sequence()
        .Append(backPage[i].transform.DOMoveX(firstBackPagePosition.x - 300f, flipTime / 2).SetEase(Ease.Linear))
        .Join(backPage[i].transform.DOMoveY(firstBackPagePosition.y + 150f, flipTime / 2).SetEase(Ease.OutCubic))
        .Append(backPage[i].transform.DOMoveX(firstBackPagePosition.x - 600f, flipTime / 2).SetEase(Ease.Linear))
        .Join(backPage[i].transform.DOMoveY(firstBackPagePosition.y, flipTime / 2).SetEase(Ease.InCubic))
        // 끝나면 Curling 종료, 페이지 번호 증가, 다음 장을 제일 위로 올리기
        .OnComplete(() => { 
            isCurling = false;
            pageNumber++;
            pages[pageNumber].SetAsLastSibling();
        });
}

 

마지막은 올라가는 포인트를 자유롭게 조정할 방법이다. Book 아래에 Curl Point란 오브젝트를 만들고, 코드에서 이 오브젝트의 위치까지 올라갔다 내려가게 작성하였다.

 

// DOTween 애니메이션 관련
public Transform curlPoint;
public float flipTime = 1f;

// 페이지를 움직인다.
private void MoveBackPage(int i)
{
    // 틀어질 경우를 대비해, 시작 위치로 이동시킨다.
    backPage[i].transform.position = firstBackPagePosition;

    // 위치를 포물선으로 이동시킨다.
    DOTween.Sequence()
        .Append(backPage[i].transform.DOMoveX(curlPoint.position.x, flipTime / 2).SetEase(Ease.Linear))
        .Join(backPage[i].transform.DOMoveY(curlPoint.position.y, flipTime / 2).SetEase(Ease.OutCubic))
        .Append(backPage[i].transform.DOMoveX(firstBackPagePosition.x - 600f, flipTime / 2).SetEase(Ease.Linear))
        .Join(backPage[i].transform.DOMoveY(firstBackPagePosition.y, flipTime / 2).SetEase(Ease.InCubic))
        // 끝나면 Curling 종료, 페이지 번호 증가, 다음 장을 제일 위로 올리기
        .OnComplete(() => { 
            isCurling = false;
            pageNumber++;
            pages[pageNumber].SetAsLastSibling();
        });
}

 

이제 테스트를 해보면

 

 

원하는 대로 넘어가지만 Mask 오브젝트의 틀어짐이 발생하는 걸 볼 수 있다.

프레임이 낮을 때 두드러지는 걸 보면, 아마 LateUpdate가 따로 계산해서... 라고 봐야 할 것 같다.

어쨌거나, 애니메이션이 끝난 후 재조정하는 방법을 임시 방편으로 넣어봤다.

 

// 위치를 포물선으로 이동시킨다.
DOTween.Sequence()
    .Append(backPage[i].transform.DOMoveX(curlPoint.position.x, flipTime / 2).SetEase(Ease.Linear))
    .Join(backPage[i].transform.DOMoveY(curlPoint.position.y, flipTime / 2).SetEase(Ease.OutCubic))
    .Append(backPage[i].transform.DOMoveX(firstBackPagePosition.x - 600f, flipTime / 2).SetEase(Ease.Linear))
    .Join(backPage[i].transform.DOMoveY(firstBackPagePosition.y, flipTime / 2).SetEase(Ease.InCubic))
    // 끝나면 Curling 종료, 페이지 번호 증가, 다음 장을 제일 위로 올리기
    .OnComplete(() => { 
        // 위치를 딱 맞춘다. (Snapping)
        mask[i].position = corner;
        mask[i].rotation = Quaternion.Euler(Vector3.zero);

        frontPage[i].position = corner - new Vector3(600f, 0f, 0f);
        frontPage[i].rotation = Quaternion.Euler(Vector3.zero);

        backPage[i].position = corner - new Vector3(600f, 0f, 0f);
        backPage[i].rotation = Quaternion.Euler(Vector3.zero);

        gradient[i].position = corner - new Vector3(300f, 0f, 0f);
        gradient[i].rotation = Quaternion.Euler(Vector3.zero);

        // Curling 종료, 넘길 페이지 번호 증가
        isCurling = false;
        pageNumber++;
        pages[pageNumber].SetAsLastSibling();
    });

 

 

이제야 제대로 됐다!


전체 코드

using DG.Tweening;
using UnityEngine;

public class PageCurl : MonoBehaviour
{
    // 전체 페이지 
    private Transform[] pages;

    // 구성 요소 (앞면, 뒷면, 마스크)
    private Transform[] mask;
    private Transform[] frontPage;
    private Transform[] backPage;
    private Transform[] gradient;

    // 넘김 효과가 실행 중인가?
    private bool isCurling = false;
    // 넘길 페이지 번호. 한 장 넘긴 후 증가시켜 다음 장을 넘긴다.
    private int pageNumber = 0;

    // 페이지의 꼭짓점과 책의 꼭짓점
    private Vector2 point;
    private Vector3 corner = new Vector3(300f, -225f, 0f);

    // BackPage의 시작 위치
    private Vector3 firstBackPagePosition;

    // DOTween 애니메이션 관련
    public Transform curlPoint;
    public float flipTime = 1f;

    public void Awake()
    {
        // 배열 초기화
        pages = new Transform[transform.childCount];
        mask = new Transform[transform.childCount];
        frontPage = new Transform[transform.childCount];
        backPage = new Transform[transform.childCount];
        gradient = new Transform[transform.childCount];
        
        // 배열에 자식들을 불러온다.
        for(int i = 0; i < transform.childCount; ++i)
        {
            // 맨 아래의 자식부터 불러온다.
            pages[i] = transform.GetChild((transform.childCount - 1) - i);

            // 나머진 pages를 기준으로 불러오니 i가 들어갔다.
            mask[i] = pages[i].GetChild(0);
            frontPage[i] = mask[i].GetChild(0);
            backPage[i] = mask[i].GetChild(1);
            gradient[i] = backPage[i].GetChild(0);
        }
        
        // 코너를 책 위치 기준으로 해야 하니 책 위치를 더해준다.
        corner += transform.position;
        // 시작 위치를 미리 받아둔다.
        firstBackPagePosition = backPage[0].transform.position;
    }

    public void LateUpdate()
    {
        // Curl 효과가 동작하는 동안
        if (isCurling)
        {
            // 넘김 효과를 실행한다.
            CurlPage(pageNumber);
        }
    }
    
    // 페이지를 넘긴다.
    public void FlipPage()
    {
        if (isCurling || pageNumber >= transform.childCount)
        {
            return;
        }

        isCurling = true;

        MoveBackPage(pageNumber);
    }

    // 페이지를 움직인다.
    private void MoveBackPage(int i)
    {
        // 틀어질 경우를 대비해, 시작 위치로 이동시킨다.
        backPage[i].transform.position = firstBackPagePosition;

        // 위치를 포물선으로 이동시킨다.
        DOTween.Sequence()
            .Append(backPage[i].transform.DOMoveX(curlPoint.position.x, flipTime / 2).SetEase(Ease.Linear))
            .Join(backPage[i].transform.DOMoveY(curlPoint.position.y, flipTime / 2).SetEase(Ease.OutCubic))
            .Append(backPage[i].transform.DOMoveX(firstBackPagePosition.x - 600f, flipTime / 2).SetEase(Ease.Linear))
            .Join(backPage[i].transform.DOMoveY(firstBackPagePosition.y, flipTime / 2).SetEase(Ease.InCubic))
            // 끝나면 Curling 종료, 페이지 번호 증가, 다음 장을 제일 위로 올리기
            .OnComplete(() => { 
                // 위치를 딱 맞춘다. (Snapping)
                mask[i].position = corner;
                mask[i].rotation = Quaternion.Euler(Vector3.zero);

                frontPage[i].position = corner - new Vector3(600f, 0f, 0f);
                frontPage[i].rotation = Quaternion.Euler(Vector3.zero);

                backPage[i].position = corner - new Vector3(600f, 0f, 0f);
                backPage[i].rotation = Quaternion.Euler(Vector3.zero);

                gradient[i].position = corner - new Vector3(300f, 0f, 0f);
                gradient[i].rotation = Quaternion.Euler(Vector3.zero);

                // Curling 종료, 넘길 페이지 번호 증가
                isCurling = false;
                pageNumber++;
                pages[pageNumber].SetAsLastSibling();
            });
    }

    // 페이지 넘김 효과를 실행한다.
    private void CurlPage(int i)
    {
        // 책 오른쪽 페이지의 우측 하단 꼭지점.
        point = backPage[i].transform.position;

        // x, y 계산
        float x = corner.x - point.x;
        float y = point.y - corner.y;

        // 세타(각도) 계산, 단위는 Degree(도)
        // x == 0인 경우를 처리하기 위해, Atan이 아닌 Atan2를 쓴다.
        float theta = Mathf.Atan2(y, x) * Mathf.Rad2Deg;

        // BackPage, FrontPage가 Mask에 영향받아 움직이지 않게, 미리 위치를 캐싱해둔다.
        Vector3 originFrontPagePosition = frontPage[i].position;
        Vector3 originBackPagePosition = backPage[i].position;

        // 오브젝트 이동. 부모 오브젝트부터 자식 오브젝트 순으로 위치를 변경해야 한다.
        // Mask의 이동할 거리 계산
        float maskX = (Vector2.Distance(point, corner) / 2) / Mathf.Cos(theta * Mathf.Deg2Rad);

        // Mask 이동 및 회전
        mask[i].position = corner - new Vector3(maskX, 0f, 0f);
        mask[i].rotation = Quaternion.Euler(0f, 0f, -theta);

        // FrontPage는 위치, 회전 고정
        frontPage[i].position = originFrontPagePosition;
        frontPage[i].rotation = Quaternion.Euler(0f, 0f, 0f);

        // BackPage의 회전은 계산한 결과대로 변경, 위치는 원래대로
        backPage[i].position = originBackPagePosition;
        backPage[i].rotation = Quaternion.Euler(0f, 0f, -2 * theta);

        // gradient도  Mask와 같게 이동 및 회전
        gradient[i].position = corner - new Vector3(maskX, 0f, 0f);
        gradient[i].rotation = Quaternion.Euler(0f, 0f, -theta);

        // 음영을 활성화한다.
        gradient[i].gameObject.SetActive(true);
    }
}

마무리

이로써 나름 길었던 책 페이지 넘기기 효과가 마무리되었다.

완성하고 보니 GNUPart 님의 코드와 비슷해졌지만, 바닥부터 시작해 올라왔다는 점에 의의를 두고 싶다.

 

원래는 저 분이 공유해주신 코드를 사용하고 책 위에 글 띄우기, 선택지 등 다른 기능 구현에 집중하려 했지만, 예제로 올려주신 파일이 깨지는 바람에 처음부터 구현해보게 되었다.

 

그래도 나름 오랜만에 수학도 써보고, 재밌었다. 아직 추가할 기능이 많은데 시간을 많이 뺏겼다는 것만 빼면?

 

그래도 다른 분들이 원리를 이해해가며 내 코드를 쓰게 된다면, 그것만으로도 충분하다고 생각한다.

 

이제 마저 게임을 완성해 출시까지 달려보자. 파이팅!


참고 자료