Unity - '한 우산 아래'

회전 플랫폼 만들기 - AutoRotator와 MovingPlatformSurface

망고와플 2026. 4. 16. 22:03

프로토타입 맵을 만들면서 회전하는 발판이 필요해졌다.

처음에는 단순히 오브젝트를 transform.Rotate()로 돌리면 될 것 같았다.

transform.Rotate(0.0f, 5.0f * Time.deltaTime, 0.0f);

하지만 회전 플랫폼은 단순히 눈에 보이는 발판만 도는 것으로 끝나지 않았다.

플레이어가 그 위에 올라갔을 때 같이 움직여야 하고, 나중에는 상자 같은 Rigidbody 오브젝트도 플랫폼 위에서 자연스럽게 밀고 당길 수 있어야 했다.

그래서 이번에는 회전 자체를 담당하는 컴포넌트와, 플랫폼이 한 프레임 동안 얼마나 움직였는지 기록하는 컴포넌트를 나눠서 만들었다.

AutoRotator
- 오브젝트를 자동으로 회전시킨다.

MovingPlatformSurface
- 회전/이동 전후의 Transform 값을 기록한다.
- 이번 프레임에 플랫폼이 얼마나 움직였는지 계산할 수 있게 한다.

이번 글에서는 우선 회전 플랫폼을 만드는 구조까지만 정리한다.

플레이어가 그 위에 자연스럽게 올라타는 처리는 다음 글에서 다룰 예정이다.

왜 그냥 Rotate만 쓰지 않았나

단순 장식 오브젝트라면 transform.Rotate()만으로도 충분하다.

하지만 이번 회전 플랫폼은 장식이 아니라 플레이어가 올라갈 수 있는 발판이다.

즉, 발판이 움직이면 그 위에 있는 플레이어나 상자도 발판의 움직임을 따라가야 한다.

문제는 Unity 물리에서 부모-자식 관계로 묶지 않은 Rigidbody 오브젝트가 회전하는 발판 위에 있다고 해서 자동으로 완벽하게 같이 움직여주지는 않는다는 점이다.

그래서 발판이 이번 물리 프레임 동안 얼마나 움직였는지를 직접 기록해두고, 그 이동량을 플레이어나 상자에게 적용할 수 있는 구조가 필요했다.

발판이 움직이기 전 위치/회전 저장
→ 발판 회전
→ 발판이 움직인 후 위치/회전 저장
→ 두 값의 차이로 이동량 계산

이 흐름을 위해 AutoRotator와 MovingPlatformSurface를 분리했다.

AutoRotator

먼저 오브젝트를 자동으로 회전시키는 컴포넌트를 만들었다.

using UnityEngine;

// 오브젝트를 자동으로 회전시키는 범용 컴포넌트.
// X/Y/Z축 회전 속도를 각각 지정할 수 있어서 맵, 장치, 장식 오브젝트에 재사용할 수 있다.
// MovingPlatformSurface보다 먼저 움직여야 플레이어가 같은 FixedUpdate에서 이동량을 따라갈 수 있다.
[DefaultExecutionOrder(-50)]
public class AutoRotator : MonoBehaviour
{
    [Tooltip("Rotation speed for each axis in degrees per second.")]
    [SerializeField] private Vector3 rotationSpeed = new Vector3(0.0f, 5.0f, 0.0f);

    [Tooltip("If true, rotate around world axes. If false, rotate around this object's local axes.")]
    [SerializeField] private bool useWorldSpace = true;

    [Tooltip("Use FixedUpdate when this object should carry physics objects such as the player.")]
    [SerializeField] private bool useFixedUpdate = true;

    private MovingPlatformSurface movingPlatformSurface;

    private void Awake()
    {
        movingPlatformSurface = GetComponent<MovingPlatformSurface>();
    }

    private void FixedUpdate()
    {
        if (!useFixedUpdate)
        {
            return;
        }

        RotateBy(Time.fixedDeltaTime);
    }

    private void Update()
    {
        if (useFixedUpdate)
        {
            return;
        }

        RotateBy(Time.deltaTime);
    }

    private void RotateBy(float deltaTime)
    {
        // 세 축 모두 0이면 회전시킬 필요가 없으므로 바로 빠져나간다.
        if (rotationSpeed.sqrMagnitude <= 0.0001f)
        {
            return;
        }

        // MovingPlatformSurface가 있으면 회전 전후를 기록해서
        // 플레이어가 같은 이동량을 따라갈 수 있게 한다.
        movingPlatformSurface?.CaptureBeforeMotion();

        Space rotationSpace = useWorldSpace ? Space.World : Space.Self;
        Vector3 deltaRotation = rotationSpeed * deltaTime;

        transform.Rotate(deltaRotation, rotationSpace);

        movingPlatformSurface?.CaptureAfterMotion();
    }

    private void OnValidate()
    {
        // 너무 큰 값을 실수로 넣어도 다루기 쉬운 범위 안에서 테스트할 수 있게 제한한다.
        rotationSpeed.x = Mathf.Clamp(rotationSpeed.x, -360.0f, 360.0f);
        rotationSpeed.y = Mathf.Clamp(rotationSpeed.y, -360.0f, 360.0f);
        rotationSpeed.z = Mathf.Clamp(rotationSpeed.z, -360.0f, 360.0f);
    }
}

여기서 핵심은 rotationSpeed다.

[SerializeField] private Vector3 rotationSpeed = new Vector3(0.0f, 5.0f, 0.0f);

Vector3를 사용한 이유는 X, Y, Z축 회전 속도를 각각 정하기 위해서다.

x = 0.0f
→ X축으로는 회전하지 않는다.

y = 5.0f
→ Y축으로 초당 5도 회전한다.

z = 0.0f
→ Z축으로는 회전하지 않는다.

즉 기본값은 Y축 기준으로 천천히 도는 플랫폼이다.

탑 주변을 도는 발판을 만들고 싶었기 때문에 기본값을 new Vector3(0.0f, 5.0f, 0.0f)로 잡았다.

Update가 아니라 FixedUpdate를 쓰는 이유

AutoRotator에는 useFixedUpdate 옵션을 넣었다.

[SerializeField] private bool useFixedUpdate = true;

단순 장식 오브젝트라면 Update()에서 돌려도 큰 문제가 없다.

하지만 이 플랫폼 위에는 플레이어나 상자 같은 Rigidbody 오브젝트가 올라갈 수 있다.

Rigidbody 이동은 물리 업데이트인 FixedUpdate()와 맞춰서 처리하는 편이 안정적이다.

그래서 기본값은 FixedUpdate()를 사용하도록 했다.

private void FixedUpdate()
{
    if (!useFixedUpdate)
    {
        return;
    }

    RotateBy(Time.fixedDeltaTime);
}

반대로 장식용 오브젝트라면 useFixedUpdate를 끄고 Update()에서 돌릴 수도 있다.

private void Update()
{
    if (useFixedUpdate)
    {
        return;
    }

    RotateBy(Time.deltaTime);
}

이렇게 해두면 같은 컴포넌트를 장식용 회전 오브젝트와 실제 발판 오브젝트에 모두 사용할 수 있다.

MovingPlatformSurface

다음으로 필요한 것은 발판의 이동량을 기록하는 컴포넌트다.

AutoRotator는 오브젝트를 돌리는 역할만 한다.

하지만 플레이어나 상자가 발판 위에 올라갔을 때 따라가려면, 발판이 이번 프레임에 얼마나 움직였는지 알아야 한다.

그래서 MovingPlatformSurface를 만들었다.

using UnityEngine;

// 플레이어를 실어 나를 수 있는 움직이는 표면임을 표시하는 컴포넌트.
// 회전/이동 전후 Transform 변화를 저장하고, 그 변화량을 플레이어가 따라갈 수 있게 제공한다.
public class MovingPlatformSurface : MonoBehaviour
{
    [Tooltip("Transform whose movement should carry the player. Empty uses this transform.")]
    [SerializeField] private Transform motionRoot;

    private Vector3 previousPosition;
    private Quaternion previousRotation = Quaternion.identity;

    private Vector3 currentPosition;
    private Quaternion currentRotation = Quaternion.identity;

    private float lastMotionTime = -1.0f;
    private bool initialized;

    private Transform MotionRoot => motionRoot != null ? motionRoot : transform;

    private void Awake()
    {
        ResetSnapshot();
    }

    private void OnEnable()
    {
        ResetSnapshot();
    }

    private void OnValidate()
    {
        // 같은 오브젝트에 붙여 두는 경우가 대부분이므로 비어 있으면 자기 Transform을 기준으로 삼는다.
        if (motionRoot == null)
        {
            motionRoot = transform;
        }
    }
}

motionRoot는 실제로 움직이는 Transform이다.

대부분은 MovingPlatformSurface가 붙은 오브젝트 자신을 기준으로 쓰면 된다.

하지만 나중에 구조가 복잡해져서 부모 오브젝트가 움직이고 자식 오브젝트가 충돌 표면만 담당하는 식으로 나눌 수도 있다.

그래서 기준 Transform을 바꿀 수 있게 해두었다.

private Transform MotionRoot => motionRoot != null ? motionRoot : transform;

motionRoot가 있으면 그것을 쓰고, 없으면 자기 자신의 transform을 사용한다.

회전 전후 기록하기

핵심은 발판이 움직이기 전과 후의 위치/회전을 기록하는 것이다.

public void CaptureBeforeMotion()
{
    // 플랫폼을 움직이기 직전의 위치/회전을 저장한다.
    // AutoRotator처럼 실제 이동을 만드는 컴포넌트가 호출해야 한다.
    Transform root = MotionRoot;

    previousPosition = root.position;
    previousRotation = root.rotation;

    initialized = true;
}

public void CaptureAfterMotion()
{
    // 플랫폼을 움직인 직후의 위치/회전을 저장한다.
    // 이전 값과 현재 값을 비교해서 이번 FixedUpdate의 이동량을 계산한다.
    Transform root = MotionRoot;

    currentPosition = root.position;
    currentRotation = root.rotation;
    lastMotionTime = Time.inFixedTimeStep ? Time.fixedTime : Time.time;

    if (!initialized)
    {
        // CaptureBeforeMotion 없이 먼저 호출된 경우에도 큰 이동량이 생기지 않게 현재 값으로 맞춘다.
        previousPosition = currentPosition;
        previousRotation = currentRotation;
        initialized = true;
    }
}

이 두 함수는 AutoRotator에서 회전 전후에 호출한다.

movingPlatformSurface?.CaptureBeforeMotion();

transform.Rotate(deltaRotation, rotationSpace);

movingPlatformSurface?.CaptureAfterMotion();

이렇게 해두면 MovingPlatformSurface는 이번 프레임에 플랫폼이 어떻게 변했는지 알 수 있다.

여기서 ?. 문법을 사용했다.

movingPlatformSurface?.CaptureBeforeMotion();

이건 movingPlatformSurface가 null이 아닐 때만 함수를 호출한다는 뜻이다.

즉, AutoRotator만 붙어 있고 MovingPlatformSurface가 없는 오브젝트도 문제 없이 회전할 수 있다.

특정 위치가 플랫폼을 따라 얼마나 움직였는지 구하기

이제 발판이 움직이기 전후 값을 가지고, 특정 월드 위치가 플랫폼 회전에 의해 어디로 이동해야 하는지 계산할 수 있다.

public Vector3 GetDeltaPositionAt(Vector3 worldPosition)
{
    if (!initialized || !HasMotionThisStep())
    {
        // 이번 물리 스텝에 기록된 움직임이 없으면 이전 프레임 값을 재사용하지 않는다.
        return Vector3.zero;
    }

    // 이전 회전에서 현재 회전까지의 차이를 구한다.
    Quaternion rotationDelta = GetDeltaRotation();

    // 입력된 위치를 플랫폼 기준 주변 좌표로 보고,
    // 플랫폼의 회전/이동 이후 위치를 계산한다.
    Vector3 previousOffset = worldPosition - previousPosition;
    Vector3 nextPosition = currentPosition + rotationDelta * previousOffset;

    return nextPosition - worldPosition;
}

이 함수는 조금 헷갈릴 수 있다.

예를 들어 플레이어가 회전 플랫폼 위의 어떤 위치에 서 있다고 하자.

플랫폼 중심이 회전하면 플레이어가 서 있던 위치도 중심을 기준으로 원을 그리며 이동해야 한다.

그래서 먼저 플레이어 위치를 플랫폼 중심 기준의 상대 위치로 바꾼다.

Vector3 previousOffset = worldPosition - previousPosition;

그다음 플랫폼이 이번 프레임에 회전한 만큼 그 상대 위치를 회전시킨다.

Vector3 nextPosition = currentPosition + rotationDelta * previousOffset;

마지막으로 현재 위치에서 다음 위치까지의 차이를 반환한다.

return nextPosition - worldPosition;

이 값이 바로 플레이어나 상자가 이번 프레임에 따라가야 할 이동량이다.

현재 위치 + 플랫폼 이동량 = 플랫폼 위에서 유지되어야 할 다음 위치

회전 변화량 구하기

회전 변화량은 이전 회전과 현재 회전의 차이로 계산한다.

public Quaternion GetDeltaRotation()
{
    if (!initialized || !HasMotionThisStep())
    {
        // 움직임이 기록되지 않은 프레임에서는 회전 변화가 없는 것으로 본다.
        return Quaternion.identity;
    }

    return currentRotation * Quaternion.Inverse(previousRotation);
}

Quaternion.Inverse(previousRotation)는 이전 회전을 되돌리는 값이다.

여기에 현재 회전을 곱하면 “이전 회전에서 현재 회전까지 얼마나 변했는지”를 얻을 수 있다.

처음에는 쿼터니언 계산이 직관적이지 않게 느껴질 수 있다.

하지만 여기서는 이렇게 이해하면 된다.

previousRotation
→ 플랫폼이 움직이기 전 회전

currentRotation
→ 플랫폼이 움직인 후 회전

currentRotation * Quaternion.Inverse(previousRotation)
→ 이번 프레임에 추가로 일어난 회전

이 값은 나중에 플레이어의 바라보는 방향을 플랫폼 회전에 맞춰 돌릴 때도 사용한다.

Y축 회전량만 따로 구하기

회전 플랫폼에서 플레이어에게 필요한 것은 대부분 Y축 회전이다.

플랫폼이 위아래로 기울어지는 경우까지 캐릭터 몸 방향에 그대로 반영하면 조작감이 이상해질 수 있다.

그래서 별도로 GetDeltaYaw()도 만들었다.

public float GetDeltaYaw()
{
    // 플레이어에게는 캐릭터라 X/Z 기울기는 따라가지 않고,
    // Y축 회전만 몸 방향에 반영한다.
    Quaternion rotationDelta = GetDeltaRotation();

    Vector3 previousForward = Vector3.forward;
    Vector3 currentForward = rotationDelta * previousForward;

    previousForward.y = 0.0f;
    currentForward.y = 0.0f;

    if (previousForward.sqrMagnitude <= 0.0001f || currentForward.sqrMagnitude <= 0.0001f)
    {
        return 0.0f;
    }

    return Vector3.SignedAngle(previousForward.normalized, currentForward.normalized, Vector3.up);
}

여기서는 Vector3.forward가 이번 프레임 회전에 의해 어느 방향으로 바뀌었는지 본다.

그리고 Y값을 0으로 만들어 수평 방향만 남긴다.

previousForward.y = 0.0f;
currentForward.y = 0.0f;

그다음 두 방향 사이의 각도를 구한다.

return Vector3.SignedAngle(previousForward.normalized, currentForward.normalized, Vector3.up);

SignedAngle을 사용하면 왼쪽으로 돌았는지, 오른쪽으로 돌았는지도 알 수 있다.

이 값은 다음 글에서 플레이어의 몸 방향을 플랫폼 회전에 맞춰 돌릴 때 사용한다.

같은 프레임의 이동량만 사용하기

중요한 점은 이전 프레임의 이동량을 잘못 재사용하면 안 된다는 것이다.

플랫폼이 이번 프레임에 움직이지 않았는데도 지난 프레임의 이동량이 남아 있으면, 플레이어나 상자가 한 번 더 밀려날 수 있다.

그래서 HasMotionThisStep()으로 이번 프레임에 기록된 값인지 확인했다.

private bool HasMotionThisStep()
{
    // 오래된 이동량이 다음 프레임까지 남아 있으면 플레이어가 두 번 끌려갈 수 있다.
    // 현재 Update/FixedUpdate에서 기록된 값인지 확인한다.
    float currentTime = Time.inFixedTimeStep ? Time.fixedTime : Time.time;

    return Mathf.Approximately(lastMotionTime, currentTime);
}

CaptureAfterMotion()에서 lastMotionTime을 저장해두고, 이동량을 읽을 때 현재 시간과 비교한다.

lastMotionTime = Time.inFixedTimeStep ? Time.fixedTime : Time.time;

이렇게 하면 이번 물리 스텝에서 실제로 움직인 플랫폼의 이동량만 사용할 수 있다.

초기값 맞추기

처음 활성화되었을 때는 이전 위치와 현재 위치가 다르면 안 된다.

그렇지 않으면 첫 프레임에 갑자기 큰 이동량이 생길 수 있다.

그래서 Awake()와 OnEnable()에서 스냅샷을 초기화한다.

private void ResetSnapshot()
{
    // 컴포넌트가 켜지는 순간의 위치를 기준점으로 삼아 첫 프레임 튐을 막는다.
    Transform root = MotionRoot;

    previousPosition = root.position;
    previousRotation = root.rotation;

    currentPosition = previousPosition;
    currentRotation = previousRotation;

    lastMotionTime = -1.0f;
    initialized = true;
}

이렇게 해두면 플랫폼이 처음 켜졌을 때 이전 위치와 현재 위치가 같은 상태로 시작한다.

Inspector 세팅

회전 플랫폼 오브젝트에는 다음 컴포넌트를 붙인다.

RotatingPlatform
- Mesh Renderer
- Collider
- Rigidbody
- AutoRotator
- MovingPlatformSurface

AutoRotator는 이런 식으로 설정했다.

Rotation Speed: (0, 5, 0)
Use World Space: true
Use Fixed Update: true

Rotation Speed는 초당 회전 각도다.

(0, 5, 0)
→ Y축으로 초당 5도 회전

Use World Space를 켜면 월드 Y축 기준으로 돈다.

탑 중앙을 기준으로 도는 구조에서는 월드 축 기준 회전이 다루기 편했다.

Use Fixed Update는 플레이어나 상자가 올라갈 발판이라면 켜두는 것이 좋다.

물리 오브젝트가 올라가는 발판이기 때문이다.

FixedUpdate() - 올라갔을 때만 기록하면 안 될까? 비용의 낭비 아닐까?

회전 플랫폼은 매 FixedUpdate()마다 회전하고, 그 전후의 위치와 회전을 기록한다.

처음에는 이런 생각도 들었다.

(플레이어나 상자가 올라갔을 때만 기록하면 되는 것 아닌가?)

실제로 그렇게 만들 수도 있다.

플랫폼 위에 올라온 Rigidbody 수를 세고, 누군가 올라와 있을 때만 이동량을 기록하는 방식이다.

플레이어가 올라옴
→ 이동량 기록 시작

플레이어가 내려감
→ 이동량 기록 중지

하지만 이번에는 그렇게 하지 않았다.

이유는 단순하다.

지금 기록 작업은 비용이 크지 않고, 오히려 조건을 추가하는 쪽이 구조를 더 복잡하게 만들 수 있기 때문이다.

현재 매 물리 프레임마다 하는 일은 대략 이 정도다.

회전 전 위치/회전 저장
오브젝트 회전
회전 후 위치/회전 저장
시간값 저장

반대로 올라갔을 때만 기록하려면 생각할 것이 늘어난다.

누가 올라왔는지 판단해야 한다.
플레이어와 상자 모두 처리해야 한다.
여러 물체가 동시에 올라왔을 때 개수를 관리해야 한다.
OnCollisionEnter / OnCollisionExit 누락도 조심해야 한다.
처음 올라온 프레임의 이동량이 어긋나지 않게 보정해야 한다.

그리고 이 플랫폼은 지금 프로토타입 단계에서 사용하는 장치다.

아직 회전 플랫폼이 수백 개 있는 것도 아니고, Profiler에서 이 부분이 병목으로 잡힌 것도 아니다.

그래서 지금은 더 단순한 구조를 선택했다.

플랫폼은 항상 자기 이동량을 기록한다.
플레이어나 상자는 필요할 때만 그 값을 읽어간다.

이렇게 해두면 누가 언제 올라오든 플랫폼은 이미 자신의 이동량을 알고 있다.

플레이어가 올라온 순간에도 별도의 준비 과정 없이 바로 이동량을 적용할 수 있다.

나중에 회전 플랫폼이 많아지고, Profiler에서 이 기록 비용이 실제 문제로 보이면 그때 최적화해도 늦지 않다.

이번 구조의 핵심

이번 작업에서 가장 중요한 점은 회전과 이동량 계산을 분리했다는 것이다.

AutoRotator
- 오브젝트를 실제로 회전시킨다.

MovingPlatformSurface
- 회전 전후를 기록한다.
- 특정 위치가 플랫폼을 따라 얼마나 이동해야 하는지 알려준다.

만약 AutoRotator 안에서 플레이어 이동까지 모두 처리했다면 처음에는 편했을 수 있다.

하지만 나중에 상자, 움직이는 발판, 밀고 당기는 오브젝트까지 추가되면 구조가 금방 복잡해진다.

그래서 회전시키는 역할과, 그 움직임을 다른 오브젝트가 따라갈 수 있게 제공하는 역할을 나눴다.

회전시키는 컴포넌트
움직임을 기록하는 컴포넌트
그 움직임을 따라가는 컴포넌트

이렇게 나누면 이후 확장이 쉬워진다.

실제로 다음 단계에서는 MovingPlatformSurface의 이동량을 플레이어에게 적용해서, 회전 플랫폼 위에 서 있을 때 플레이어가 자연스럽게 같이 움직이도록 만들었다.

정리

이번에는 회전 플랫폼의 기본 구조를 만들었다.

단순히 transform.Rotate()로 오브젝트를 돌리는 것에서 끝내지 않고, 나중에 플레이어나 상자가 그 위에 올라갈 수 있도록 플랫폼의 이동량을 기록하는 구조를 같이 만들었다.

핵심은 다음과 같다.

1. AutoRotator는 오브젝트를 자동으로 회전시킨다.
2. 회전 속도는 Vector3로 받아 X/Y/Z축을 따로 설정할 수 있다.
3. 물리 오브젝트가 올라가는 발판은 FixedUpdate에서 회전시키는 편이 좋다.
4. MovingPlatformSurface는 회전 전후의 위치와 회전을 기록한다.
5. GetDeltaPositionAt()으로 특정 위치가 플랫폼을 따라 얼마나 이동해야 하는지 계산한다.
6. GetDeltaYaw()로 Y축 회전량만 따로 얻을 수 있다.

이번 글은 회전 플랫폼 자체를 만드는 데 집중했다.

다음 글에서는 이 이동량을 플레이어에게 적용해서, 회전 플랫폼 위에서도 플레이어가 밀려나지 않고 자연스럽게 함께 움직이도록 만들 예정이다.

P.S.
이번 프로토타입에서는 회전 플랫폼을 주로 수평 발판으로 사용했다.
그래서 플레이어 몸 방향에는 Y축 회전만 반영했다.

만약 기울어지는 발판이나 경사면까지 다룬다면 이야기가 달라진다.
X/Z축 회전을 그대로 플레이어에게 적용할지,
아니면 위치만 따라가고 몸 방향은 수평으로 유지할지 따로 판단해야 한다.

지금은 3D 플랫폼 게임의 기본 조작감을 우선해서,
캐릭터가 발판의 기울기에 맞춰 쓰러지거나 기울어지지 않도록 Y축 회전만 사용했다.