GridUnlocker

GridUnlocker에 대해서

저번 글에서 간단한 게임 기획을 해 보았습니다. 이번 글에서는 그 기획을 실제로 옮기면서 일어난 일들과, 진행 정도에 대해 써보겠습니다.

GridUnlocker는 레이저를 적절하게 한쪽 끝에서 다른 쪽에 있는 목표까지 인도하는 게임입니다. 한 쪽에서 쏘아진 레이저는 중간에 여러 판들에 의해 가로막히는데, 특정한 방식으로 판을 배열해서 반대쪽에 놓인 목표까지 레이저를 쏘아 보내야 합니다. 이 게임은 제가 처음 만들어보는 VR 게임이자 혼자 만들어보는 게임으로써, 이동과 팔 휘두르기 없이 정적인 분위기에서 퍼즐을 풀 수 있도록 기획되었습니다. (의도적으로 게임의 스코프를 제한했습니다) 이 게임이 어떤 게임인지 자세히 알아보려면 이전 글을 참조하시기 바랍니다.

기획을 실제로 옮기기 위해서 Unity에서 기본적으로 제공하는 라이브러리와 튜토리얼을 이용해보았습니다. 처음에는 튜토리얼에서 카메라와 손만을 남기고, 적당히 기본 도형으로 퍼즐을 만든 뒤 조합해넣으면 되겠거니… 하고 생각했습니다. 실제로 구현한 방식도 크게 다르지 않고요. 하지만 몇 가지 문제가 생겼습니다. 자잘한 문제를 제외하더라도, 독립적인 글을 작성해도 될 정도의 문제들도 있었습니다.

그 외에도 프로그래밍에 대해 더 배우는 기회로 삼기로 했습니다. 덕분에, Unity 엔진에서 제공하는 여러 기능들과 프로그래밍 패턴에 더욱 익숙해지는 계기가 되었습니다.

이동과 회전 제한

개요

기획한 퍼즐이 작동하기 위해서는, 퍼즐 조각이 마음대로 움직이지 않고 특정한 방식으로만 움직여야 합니다. KBM이나 컨트롤러를 이용한다면 간단한 일이었을겁니다. 특정 조각을 선택하고 방향키를 누르면 기존에 정해 둔 변수에 따라 특정 방식으로만 움직이게 만들면 됩니다.

GridUnlocker의 퍼즐 모양.

하지만 VR 게임에서는 다릅니다. 저는 이 게임의 퍼즐 조각을 젠가나 레고 조각과 같이 직관적으로 움직일 수 있었으면 했습니다. 그렇기 때문에 직접 조각을 잡고 손을 움직이면, 손의 움직임을 어느 정도만 따라가는 것을 원했습니다. 문제는, 퍼즐 판도 (마치 상자를 움직이듯이) 움직이는 것을 원했다는 것이죠.

XR interaction toolkit에 기본적으로 포함된 XR Grab Interactor나, 이 컴포넌트를 상속하면서 튜토리얼에 포함된 XR offset Interactor을 컴포넌트로 붙이면 간단하게 “손으로 잡고 움직일 수 있는” 물체를 만들 수 있습니다. 두 컴포넌트가 작동하는 방식은 조금 다릅니다. 전자는 (Grab을 하게 되면) 물체의 정해진 부분이나 (위치를 정하지 않을 경우 기본적으로) 손에서 가장 가까운 부분이 손에 달라붙습니다. 특정한 방법으로 손에 고정되어야 하는 도구에 적합하죠. 후자의 경우, 물체는 이동하지 않고 (손과 물체 사이의 “offset”을 유지한 채) 손의 움직임에 따라 물체가 이동하게 됩니다. 퍼즐 판을 움직이기 위해서는 후자의 방법을 쓰는 것이 좋겠죠.

하지만 이 Interactor 컴포넌트를 사용하면 물체가 손의 움직임에 그대로 이동하고 회전하게 됩니다. 우리는 경우에 따라 특정 방향(x축 방향으로 배열되어 있으므로, y축과 z축 방향)으로 이동하거나 특정 축(x축)으로만 회전을 하고 싶습니다. 이를 구현하려면 XRBaseInteractor를 상속한 Interactor를 새롭게 만들어야 합니다. XRRestrictedMovement 정도로 이름을 붙이고, 레퍼런스와 소스를 살펴보았습니다.

(대략 여기에 레퍼런스)

기본적으로 XR Offset Interactor의 코드를 가지고 옵니다. 하지만, 실질적으로 물체를 움직이는 것은 XR Grab Interactor 쪽에 위치하므로 해당 부분의 코드도 가지고 왔습니다. 물체를 움직이는 데에는 여러 가지 방법이 있지만 우리는 Kinematic한 RigidBody를 움직이는 것으로 충분하기 때문에 Kinematic 부분을 살펴보도록 하죠.

구현

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;


[DisallowMultipleComponent]
[RequireComponent(typeof(Rigidbody))]
public class XRRestrictedMovement : XRBaseInteractable
{
    public bool allowRotation = true;
    public bool allowTranslateY = true;
    public bool allowTranslateZ = true;

    ...

    public override void ProcessInteractable(XRInteractionUpdateOrder.UpdatePhase updatePhase){}

    
    protected override void OnSelectEntering(SelectEnterEventArgs args){}

    
    protected override void OnSelectExiting(SelectExitEventArgs args){}
}

위에 위치한 3개 Bool 변수로 회전과 이동을 허용할지 결정합니다. 기본적으로 회전과 이동을 허용하게 되어 있습니다만, 인스펙터에서 제한할 수 있습니다. XR Interaction Toolkit 버전 1.0.0pre2 기준으로, 위에 있는 3개 함수를 오버라이드해야 합니다. OnSelectEntering()OnSelectExiting()은 각각 손이 물체를 ‘잡기 시작할 때’와 ‘잡는 것을 마칠 때 호출됩니다. 잡는 위치나 offset 등을 여기서 초기화하고 폐기하면 됩니다. 문제는 ProcessInteractable()입니다. 이 함수는 (실제로는 어떤 단계에서 물체를 이동시킬지에 따라 달라지지만) 간단히 생각하면 Update()와 같이 물체를 잡고 있을 때 매 프레임마다 호출된다고 생각하면 됩니다. 여기서 어떻게 이동을 제한할 수 있을까요?

  public override void ProcessInteractable(XRInteractionUpdateOrder.UpdatePhase updatePhase)
    {
        // In this step, object has no parent. if must refer original parent, use m_OriginalSceneParent
        if (isSelected)
        {
            if (updatePhase == XRInteractionUpdateOrder.UpdatePhase.Fixed)
            {
                if (allowTranslateY || allowTranslateZ)
                {
                    m_Rb.velocity = Vector3.zero;
                    // get center of mass relative to transform center
                    Vector3 rigidBodyPosition = m_Rb.worldCenterOfMass;
                    // change world location of grabber relative to this transform and calc difference
                    // difference of point == direction
                    Vector3 difference = m_OriginalSceneParent.transform.InverseTransformDirection(m_GrabbingInteractor.attachTransform.position - rigidBodyPosition);
                    // Find the local position difference in local location
                    var diffy = allowTranslateY ? difference.y : 0f;
                    var diffz = allowTranslateZ ? difference.z : 0f;
                    // calculate final position delta and check against original parent
                    var positionDelta = new Vector3(0, diffy, diffz);
                    var finalPosition = m_OriginalSceneParent.transform.InverseTransformPoint(m_Rb.position) + positionDelta;

                    // Limit desired movement within specified length
                    finalPosition.y = Mathf.Clamp(finalPosition.y, m_TranslationRangeMin, m_TranslationRangeMax);
                    finalPosition.z = Mathf.Clamp(finalPosition.z, m_TranslationRangeMin, m_TranslationRangeMax);

                    //var worldPositionDelta = transform.TransformDirection(positionDelta);
                    // apply location difference

                    // change back to world position
                    var finalWorldPosition = m_OriginalSceneParent.transform.TransformPoint(finalPosition);

                    m_Rb.MovePosition(finalWorldPosition);
                }

                if (allowRotation)
                {
                    // check world rotaion or local rotation(is desired)
                    m_Rb.angularVelocity = Vector3.zero;

                    // 1. 필요한 변수들을 가져온다
                    // 이 오브젝트의 현재 Rotation (Quaternion)
                    Quaternion rigidBodyRotation = m_Rb.transform.rotation;
                    // 오브젝트의 Red arrow가 향하는 각도
                    Vector3 xAxisOfThis = Vector3.right;

                    // Hand의 직전 위치 m_GrabbedPosition
                    // Hand의 현재 위치 m_GrabbingInteractor.attachTransform.position
                    Vector3 currentInteractorPosition = m_GrabbingInteractor.attachTransform.position;
                    // m_Rb의 현재 위치를 중심으로 삼아야 하지 않나.
                    Vector3 oldCenterToController = m_GrabbedPosition - transform.position;
                    oldCenterToController.Normalize();
                    Vector3 centeroToController = currentInteractorPosition - transform.position;
                    centeroToController.Normalize();

                    // 2. 오브젝트 현재 위치에 대비해 각도를 계산한다.

                    // 이 오브젝트의 각도 대비 전과 현재 hand의 이동에 의해 달라진 각도(float)
                    float relativeInteractorAngle = Vector3.SignedAngle(oldCenterToController, centeroToController, xAxisOfThis);
                    if (relativeInteractorAngle < 0) relativeInteractorAngle = 360 + relativeInteractorAngle;

                    // 3. 이 각도만큼 위에서 구한 axis 대비로 회전해준다.
                    Quaternion requiredRotation = Quaternion.AngleAxis(relativeInteractorAngle, xAxisOfThis);

                    // 4. Rigidbody를 (현재 회전한 각도 * 3에서 구한 각도)만큼 회전시킨다.
                    m_Rb.MoveRotation(rigidBodyRotation * requiredRotation);

                    // 5. Position을 Update해준다.
                    m_GrabbedPosition = currentInteractorPosition;
                }

            }
        }
    }

물체가 선택되고, Fixed Phase인 경우, 이 함수는 이동 혹은 회전이 허용되었을 때에 따라 각각 새로운 위치로 이동을 시켜줍니다.

간단하게 생각하면, RigidBody에 존재하는 Constraint를 이용하면 될 듯 싶습니다. 하지만, RigidBody의 이동 제한은 World 좌표계에서 작동하고, 회전 제한은 Local 좌표계에서 작동합니다. 게다가, Grab된 오브젝트는 기본적으로 parent로부터 분리되게 됩니다. 즉, Constraint를 이용할 경우 퍼즐의 위치와 회전 각도와는 상관 없이 World 좌표계에서의 이동과 회전이 제한되게 됩니다. 이 문제를 해결하기 위해 벡터와 쿼터니온을 이용해야만 했습니다.

상속한 컴포넌트의 public 변수가 인스펙터에 보이지 않는 문제

Unity 엔진에서는 기본적으로 직렬화 가능한 public 변수를 인스펙터에 노출시킵니다. 개발자는 인스펙터에서 원하는 대로 변수를 지정할 수 있습니다. 하지만 제목 그대로, 상속한 컴포넌트에서 정의한 public 변수가 노출되지 않고 Base 컴포넌트의 public 변수만 노출되는 문제가 있었습니다.

이 문제는 Assets/Editor 폴더에 다음과 같은 커스텀 인스펙터 스크립트를 넣어주면 해결할 수 있습니다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(XRRestrictedMovement))]
public class MovementConfig : Editor
{
    public override void OnInspectorGUI()
    {
        base.OnInspectorGUI();
    }
}

이 코드는 XRRestrictedMovement 클래스에 대해 인스펙터에 보이는 그 에디터 화면을 새롭게 정의합니다. OnInspectorGUI()를 재정의하는 것으로 에디터 단계에서 작동하는 기능들을 구현하거나 데이터를 보여주는 방법을 바꿀 수 있습니다. 만약 다른 정의 없이 base(즉 기본 Editor)의 OnInspectorGUI()를 그대로 불러온다면, 원래 일반적으로 에디터에서 보이는 GUI가 보이게 됩니다. 신기하게도, 이렇게 하면 Base 클래스와 상속받은 클래스의 public 변수들을 기본적으로 보여주게 됩니다.

테두리 Highlight

물체를 잡을 수 있을 때 시각적, 청각적, 촉각적 피드백을 주어야 합니다. 시각적 피드백을 주기 위해, 간단한 셰이더를 튜토리얼에서 가져오기로 했습니다. 마침 GlassOutlineShader.shadergraph라는 셰이더가 있었습니다. 목적도 비슷하고, 기능도 하는 것 같아서, 복사본을 만들어 여러 모로 뜯어보기로 했습니다.

셰이더 그래프에 변수 하나를 추가해 준 뒤, 해당 변수를 원래 Base Color 대신 참조하게 만들어서 외부에서 기본 색상을 건드릴 수 있도록 했습니다.

다음으로 퍼즐 판 내부에 있는 Renderer들에 신호를 보내는 스크립트가 필요합니다. 튜토리얼에서는 GameObject 자체가 Renderer를 가지고 있거나 Child로 단일 Renderer를 지정할 수 있게 되어 있고, 이 Renderer에 신호를 보내주는 스크립트가 존재합니다. 하지만 우리 게임의 경우, 단일 GameObject 아래 Children들이 각각 Renderer들을 가지고 있고, 이 Renderer들에 한 번에 신호를 보내야 합니다. 결국 Script를 또 하나 만들었습니다.

    private void Start()
    {
        if(Renderers == null || Renderers.Length == 0)
        {
            Renderers = GetComponentsInChildren<Renderer>();
        }

        m_HighlightActiveID = Shader.PropertyToID("HighlightActive");
        m_Block = new MaterialPropertyBlock();
        m_Block.SetFloat(m_HighlightActiveID, m_Highlighted);
        for (int i = 0; i < Renderers.Length; i++)
        {
            Renderers[i].SetPropertyBlock(m_Block);
        }

    }

Start 단계에서 Renderers 배열에 Children에 있는 Renderer들을 가져와 저장하고, 이들을 순회하면서 초기 Highlight 여부를 지정하게 만들었습니다. public 함수로 Highlight를 주거나, 제거할 수 있게 만들었습니다.

청각적 피드백은 적당한 사운드를 찾아서 넣을 예정입니다. 촉각적 피드백의 경우 XR Base Interactor에서 바로 Haptic을 제어할 수 있었습니다.

레이저의 송수신

이 게임에서 레이저는 게임 퍼즐의 핵심적인 부분이라고 할 수 있습니다. 레이저는 레이저 발사기에서 나와서, 퍼즐에만 충돌해야 하고, 퍼즐 조각 중에 레이저 수신기에 부딛히면 레이저 수신기가 상식적인 방식으로 작동해야 합니다. 즉, 레이저가 처음 들어올 때/레이저를 받고 있을 때/레이저가 꺼졌을 때의 세 타이밍 모두 함수로 접근할 수 있어야 합니다. 함수로 접근하는 방법은 C# delegate와 event를 이용할 수도 있고 UnityEvent를 이용할 수도 있지만, 저는 인스펙터에서 다루고 싶었기 때문에 후자를 선택했습니다. 자세한 내용은 다음 파트에서 다루겠습니다.

Ray를 쏘아주기 위해, 간단하게 Physics.Raycast()를 이용해주었습니다.

  if (isLaserFiring)
        {
            // cast ray toward x position
            lineRenderer.enabled = true;

            // layermask 12 is LaserBlocker
            int layerMask = 1 << 12;
            

            RaycastHit hit;
            if (Physics.Raycast(transform.position, transform.TransformDirection(Vector3.right), out hit, Mathf.Infinity, layerMask))
            {
                // if ray hit object with tag "Interactable":
                // then try to activate it.
                if (hit.collider.CompareTag("Interactable") && hit.collider.gameObject.GetComponent<LaserEvent>() != null)
                {
                    // target is set and interactable
                    target = hit.collider.gameObject;
                    target.GetComponent<LaserEvent>().LaserIsActive();
                }
                else
                {
                    // something else is hit; set target to null
                    if (target != null)
                    {
                        target.GetComponent<LaserEvent>().LaserIsInactive();
                        target = null;
                    }

                }
                lineRenderer.SetPosition(1, Vector3.right * hit.distance);

                // draw line with linerenderer from here to transform ray just hit
            }
            else
            {
                // Not hit.
                // nothing is hit; set target to null;
                if (target != null)
                {
                    target.GetComponent<LaserEvent>().LaserIsInactive();
                    target = null;
                }

                // draw line from here to long distance;
                lineRenderer.SetPosition(1, Vector3.right * 1000);
            }

        }
        else
        {
            // stop drawing line
            lineRenderer.enabled = false;
            if (target != null)
            {
                target.GetComponent<LaserEvent>().LaserIsInactive();
                target = null;
            }


        }

특정한 오브젝트에만 충돌판정을 시키기 위해 layer mask를 사용했습니다. int 형식의 레이어 마스크에 bit masking을 해주면 특정한 layer mask를 가지는 GameObject에만 충돌하도록 만들 수 있습니다. 손이나 다른 오브젝트에는 충돌하지 않고 그대로 통과하므로 퍼즐을 풀 때 손에 레이저가 가리지 않습니다.

Raycast에는 콜라이더와 중요한 차이점이 있는데, Collider의 충돌이 끝날 때(OnColliderExit())와 달리, Raycast가 중단될 때의 이벤트가 존재하지 않는다는 점입니다. 그래서 직전에 충돌한 오브젝트를 캐시해두고, 그 오브젝트가 존재하는지, 어디에 충돌했는지에 따라서 처리를 다르게 해 주어야 합니다. 그래서 Ray가 충돌했는지, Ray와 hit 오브젝트가 가지는 관계, 그리고 직전에 충돌했던 오브젝트가 존재하는지에 따라서 각 경우를 나누어 처리했습니다.

콜라이더와 리지드바디가 중첩된 상황에서 생각해두어야 할 요소가 있습니다. hit.collider는 콜라이더만 체크하고, hit.rigidbody는 리지드바디를 체크합니다. hit.transform은 콜라이더와 리지드바디 둘 다를 체크합니다.

Line renderer

Line renderer의 transform은 일반적인 transform과 다르게 작동합니다. 특정한 점들을 지정해주면 그 점들을 잇는 선분을 화면에 그려주는데, 이 때 transform은 선분 전체의 중간 지점으로 보이게 됩니다. 다른 오브젝트와 함께 레이저 발사기를 0.05unit 단위로 배치해야 하는 입장에서 성가신 측면이 있습니다.

결론만 말하면, Line renderer를 가진 오브젝트를 하위 오브젝트로 분리하고, Line renderer의 property에서 Use World Space를 체크하면 일반적인 transform에서 local transform을 다루듯이 line renderer의 정점을 제어할 수 있고, 동시에 부모 오브젝트의 transform도 우리가 일반적으로 기대했던 것처럼 에디터에서 보이게 됩니다. 이에 따라서, 기본적으로 정점을 lineRenderer.SetPosition(0, Vector3.zero); lineRenderer.SetPosition(1, Vector3.right * 1000);로 지정하면 local 기준으로 Vector3.right 방향으로 1000 unit 길이의 선분이 그려지고, 중간에 lineRenderer.SetPosition(1, Vector3.right * hit.distance);을 하게 되면 local 기준으로 내 위치((0, 0, 0))에서부터 right 방향으로 ray가 충돌한 지점까지 선분이 그려지게 됩니다(Ray는 right 방향으로 쏘았기 때문이죠).

그 외 주제들

UnityEvent를 이용해 인스펙터에서 콜백을 걸어주는 방법, 클리어 여부를 검사하는 방법, Prefab 시스템을 이용해 퍼즐을 저장한 메커니즘, 인게임 UI에 대해서는 다음 글에서 다루겠습니다.

*****
긍정적인 영향을 주는 사람이 됩시다
Served using home raspberry pi 3 B+
☕ Pudhina theme by Knhash 🛠️