프로젝트/류 Ryu

Digital Twin에서의 이슈들

화릿불 2024. 8. 26. 22:00

 

들어가며


지난번에는 Unity에서 현실 세계에서 작동하는 로봇의 ROS2 데이터를 받아와 가상 현실로 보여주기 위한 작업을 마쳤습니다.

 

지금까지 저는 현실 세계의 데이터를 가상 환경에서 적용해 본 경험이 없어 어떤 문제가 발생할지 예측하지 못했고 또한 오랜만에 유니티였기 때문에 툴을 다루는데 있어 능숙하지 못했기에 디지털 트윈을 구현하는 과정에서 만난 이슈들과 해결방법, 그리고 중요하다고 생각되는 점들을 모두 적어보고자 합니다.

 

좌표계


제가 만났던 이슈 중 첫 번째는 현실과 가상의 좌표계 차이였습니다.

 

처음 ROS2 TFMessage를 봤을 때, translation X, Y, Z와 rotation X, Y, Z, W 를 받아와서 출력할 수 있었기 때문에 좌표계에 대한 고민은 하지 않았습니다. 받아온 정보 값 그대로 오브젝트에 할당해주면 될 것이라 생각했기 때문입니다. 하지만 해당 메시지를 바탕으로 오브젝트의 좌표를 이동시키면서 문제가 생겼음을 인지할 수 있었습니다.

 

정보통신 기술 용어 해설
Unity 내의 좌표계

 

좌표계의 차이


현실 세계에서 로봇은 X, Y 축으로 움직이고 있었습니다. 즉 로봇의 높낮이에 관여하는 축은 Z축이었습니다. 하지만 유니티의 좌표계는 이와 달리 X, Z축으로 움직이며 높낮이는 Y축에 의해 변경되고 있었고 이 때문에 별다른 매핑이나 필터링 없이 X, Y, Z 축에 값을 할당하는 것은 말도 안되는 오브젝트의 움직임을 만들어내곤 했습니다.

 

현실의 좌표 계산
거리를 통한 비율 계산

 

 

이를 해결하기 위해 먼저 현실 세계의 세트장과 로봇, 섹터의 크기등을 바탕으로 가상의 세계에 어느정도 비율로 표현되어야하는지를 계산하고 실제 로봇이 움직인 거리를 바탕으로 가상 환경에서 움직일 거리를 계산해주었습니다. 또한, 로봇이 움직이는 방향을 통해 유니티의 좌표계에서는 어떤 축이 증가하고 감소하는지를 파악했고 다음과 같은 정보를 얻을 수 있었습니다.

 

  • Translation ( X, Y, Z )

Translation 현실 유니티

가로 방향 진행 +, 0, 0 0, 0, +
세로 방향 진행 0, +, 0 -, 0, 0

 

( X , Y , Z ) → ( -Y, Z, X ) 로 매핑

 

  • Rotation ( X, Y, Z, W )

Rotation 현실 유니티

정면 0, 0, 0, 1 0, 0, 0, 1
후면 0, 0, 0, -1 0, 0, 0, -1
좌면 0, 0.7, 0, 0.7 0, -0.7, 0, 0.7
우면 0, -0.7, 0, 0.7 0, 0.7, 0, 0.7

 

( X , Y , Z , W ) → ( Y , -Z , X , W ) 로 매핑

 

// 첫 번째 TransformStamped 메시지 추출
TransformStamped transform = message.transforms[0];

//  현실      가상
// x 증가 -> z 증가
// y 증가 -> x 감소
// 현실좌표와 가상좌표의 스케일차이가 있으므로 각각 맞춰서 보정
// 정확하지는 않으나 1550, 1450으로 보정
// 소수점 몇자리까지 사용할지 미정
// z 4 ~ 40
// x 7 ~ 42
UnityEngine.Vector3 translation = new UnityEngine.Vector3(
            34.0f + (Mathf.Round(-(float)transform.transform.translation.y * 1550000) / 100000.0f),
            0.0f, // Y값은 사용되지 않음. 날아갈 일 없음
            4.0f + (Mathf.Round((float)transform.transform.translation.x * 1600000) / 100000.0f)
        );

// 새로운 목표 위치 계산후 할당
targetPosition = translation;

// 현실에서 들어오는 방향 데이터
// 직진 -> z   0   w 1
// 후진 -> z - 1   w 0
// 좌   -> z   0.7 w 0.7
// 우   -> z - 0.7 w 0.7

// 회전 데이터 추출 (소수점 4자리 반올림)
float x = Mathf.Round((float)transform.transform.rotation.x * 100000) / 100000.0f;
float y = Mathf.Round((float)transform.transform.rotation.y * 100000) / 100000.0f;
float z = Mathf.Round((float)transform.transform.rotation.z * 100000) / 100000.0f;
float w = Mathf.Round((float)transform.transform.rotation.w * 100000) / 100000.0f;

// 받은 회전값으로부터 회전 Quaternion 생성
UnityEngine.Quaternion newRotation = new UnityEngine.Quaternion(0, -z, 0, w);

// 회전 계산 후 할당
targetRotation = newRotation;

이용하고 있는 ROS2 메시지가 ROS에서 매핑된 좌표계를 기준으로 정보를 제공하고 있기 때문에 한번의 계산을 더 해주어야했습니다. 실제 세트장은 231mm x 235.5mm 였지만 ROS상 좌표는 10cm당 1로 2.31 x 2.355로 표현되고 있었기 때문입니다.

 

이를 바탕으로 가상 환경 전체 사이즈를 측정하고 실제 로봇의 움직인 좌표에 15배에 달하는 값을 곱해줌으로써 가상 환경에서의 움직임을 현실세계에 맞출 수 있도록 매핑하였으며 소수점 자릿수를 이용해 오차 및 오브젝트 움직임의 안정성을 추구했습니다. 이는 위의 코드에서도 확인해 볼 수 있습니다.

 

레이캐스트


두 번째 이슈는 제가 처음으로 유니티의 3D를 작업해봤기 때문에 생긴 레이캐스트 이슈였습니다.

 

기존 유니티를 2D로만 활용했기 때문에 오브젝트나 UI에 대한 클릭, 호버링과 같은 처리는 버튼과 같은 컴포넌트를 이용해서 처리해왔습니다. 하지만 3D 모델에는 이러한 작업이 먹히지 않기 때문에 다른 방법을 찾아야했고 사용할 수 있는 방법은 레이캐스트였습니다.

 

레이캐스트를 사용하는 것은 생각보다 어렵지 않은 작업이었습니다.

상호작용이 필요한 오브젝트에 적절한 Collider를 세팅하고 Event System을 Scene에 배치한 후 코드상에서 레이캐스트를 통해 물체를 감지하고 이벤트를 처리해주면 되었습니다.

 

처음 저는 레이캐스트를 관리하는 클래스를 따로 두지 않고 클릭시 이벤트가 발생해야하는 모든 오브젝트의 스크립트에 레이캐스트를 두어 사용했습니다.

 

이렇게 사용했을 때 문제는 겹쳐진 오브젝트의 경우 예를 들어, UI와 오브젝트가 겹쳐진 상태에서 클릭되면 UI에 대한 메서드와 오브젝트 클릭에 대한 메서드가 동시에 처리된다는 것이었습니다. UI는 당시 버튼을 따로 붙혀서 사용하고 있었는데 레이캐스트 동작 방식과 버튼 동작 방식을 동시에 사용하면서 따로 처리해주는 것은 무리가 있다고 판단했고 모든 이벤트 처리를 하나의 레이캐스트 스크립트를 통해 진행하기로 결정하였습니다.

 

UI와 3D 모델 레이캐스트의 분리


// 레이캐스트 처리 로직 한번에 하나의 이벤트만 처리
// 마우스 왼쪽 버튼 클릭이 들어왔다면
if (Input.GetMouseButtonDown(0))
{
    // 현재 마우스의 위치에 PointerEventData 생성
    PointerEventData pointerData = new PointerEventData(EventSystem.current)
    {
        position = Input.mousePosition
    };

    // RaycastAll을 사용하여 모든 UI 오브젝트 감지
    List<RaycastResult> raycastResults = new List<RaycastResult>();
    EventSystem.current.RaycastAll(pointerData, raycastResults);

		// 클릭된 오브젝트가 있는지 확인
    if (raycastResults.Count > 0)
    {
        // 클릭된 UI 오브젝트들 중 가장 먼저 감지된 UI부터 먼저 처리
        GameObject clickedUIObject = raycastResults[0].gameObject;

				// UI가 활성화되어 있는 경우, UI를 꺼지게 하는 함수 호출
        if (raycastResults[0].gameObject.layer == LayerMask.NameToLayer("UI"))
        {
            // UI 컨트롤러를 부모 패널에서 찾기
            Transform parentTransform = clickedUIObject.transform;
            UIController uiController = null;

            while (parentTransform != null)
            {
                uiController = parentTransform.GetComponent<UIController>();
                if (uiController != null)
                {
                    break;
                }
                parentTransform = parentTransform.parent;
            }

            // UIController가 있는 경우 UI를 켜거나 끄는 함수 호출
            if (uiController != null)
            {
                uiController.UIOnOff(uiController.gameObject);
            }
        }
    }
    else
    {
        // UI 오브젝트 클릭이 없는 경우 3D 오브젝트 처리
        Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
        RaycastHit[] hits = Physics.RaycastAll(ray, Mathf.Infinity);

        if (hits.Length > 0)
        {
            // 가장 가까운 물체를 선택
            RaycastHit closestHit = hits[0];

            foreach (RaycastHit hit in hits)
            {
                if (hit.distance < closestHit.distance)
                {
                    closestHit = hit;
                }
            }

						// 선택된 물체의 레이어를 바탕으로
            int layer = closestHit.collider.gameObject.layer;

						// 레이어 값을 비교해서 적절한 함수 호출
						// 로봇 레이어 함수
            if (layer == LayerMask.NameToLayer("Robot"))
            {
                closestHit.transform.GetComponent<RosSubscriber>().UIOnOff();
            }
            // 섹터 레이어 함수
            else if (layer == LayerMask.NameToLayer("Sector"))
            {
                closestHit.transform.GetComponent<Sector>().UIOnOff();
            }
        }
    }
}

기본적으로 레이캐스트를 통해 감지된 물체의 tag나 layer로 해당 물체가 무엇인지 판별하고 그에 따른 동작을 할 수 있도록 만들 수 있었지만 UI는 이에 해당하지 않았습니다. 물체를 감지하는데 사용되는 레이캐스트와 UI를 감지하는데 사용되는 레이캐스트가 서로 달랐기 때문입니다.

 

저는 물체와 UI에 대한 경우를 모두 한번에 제어하면서도 제가 생각하기에는 오브젝트의 클릭보다는 UI의 클릭이 우선시 되어야한다고 생각했기 때문에 위와 같이 UI를 먼저 감지 및 처리하고 감지된 UI가 없을 경우 오브젝트를 감지 및 처리하는 코드를 작성하게 되었습니다.

 

기존 버튼으로 할당 및 진행되던 모든 작업에 대해 다시 설계해야하는 번거로움이 있었지만 훨씬 더 깔끔하고 생각했던 대로 동작하는 결과를 만들 수 있었습니다.

 

싱글톤 패턴


세 번째는 싱글톤 디자인 패턴입니다.

 

프로그램을 실행하면 바로 Main 화면이 보여지는 구조였는데 이는 사용자 친화적이지 못하다고 생각했고 프로그램이 유연하지 못하게 만드는 원인이라고 생각했습니다.

 

서버를 사용하는게 아닌 상황에서 네트워크의 환경이 원할하지 못할 경우 다른 네트워크를 사용하게 되거나 IP주소의 변경이 발생하게 될 수도 있는데 스크립트에서 IP주소를 입력해 저장하고 사용하는 방식으로는 변경 때마다의 번거로움과 프로그램의 유연성을 해친다고 생각해 프로젝트를 소개할 수 있는 로그인페이지를 통해 연결할 IP주소를 입력하고 시작하는 방식으로 프로그램을 수정할 필요성을 느끼게 되었습니다.

 

이 과정에서 다른 씬으로 넘어가는 상황이 발생할 수 있고 이 때 데이터를 넘겨줄 수 있는 무언가가 필요했습니다. 물론, 로그인 페이지에서 서버와 연결하여 IP주소에 대한 정보를 저장하고 불러오는 방식도 사용할 수 있지만 프로그램 내부에서 해결할 수 있는 작업이라고 생각해 오히려 불필요한 서버 사용을 일으킬 수 있다고 생각했습니다.

 

이에 사용한 방법은 파괴되지 않는 오브젝트를 생성해서 사용하는 것이었습니다.

기본적으로 유니티는 씬이 전환될 때마다 모든 오브젝트들이 파괴, 삭제되고 새롭게 생성이 됩니다. 이 과정에서 데이터를 전달할 수 있는 방법으로는 파괴되지 않는 오브젝트에 데이터를 담는 스크립트로 필요한 데이터를 전달하는 방법이 있습니다.

 

하지만 프로젝트가 로그인 페이지에서 메인 페이지로 다시 메인 페이지에서 로그인 페이지로 넘나들 수 있도록 설계하다 보니 자연스럽게 파괴되지 않는 오브젝트들이 여럿 생성되며 프로그램에 치명적인 오류를 발생시킬 우려가 있었고 이를 해결하기 위해 기존에 알고 있었던 디자인 패턴인 싱글톤 패턴을 도입하게 되었습니다.

 

싱글톤 패턴


모든 클라이언트가 하나의 인스턴스에 동일하게 접근 및 공유

 

싱글톤(Singleton) 패턴은 특정 클래스의 인스턴스를 단 하나만 만들고, 그 인스턴스를 어디서든지 접근할 수 있도록 하는 디자인 패턴으로 이는 전역 변수를 사용하지 않고도 애플리케이션 전반에 걸쳐 동일한 인스턴스를 공유할 수 있도록 하는 방법입니다.

 

이 패턴의 주요 특징으로는 클래스의 인스턴스가 단 하나만 생성되고 내부에서 관리되며 외부에서는 직접 생성이 불가합니다. 싱글톤은 전역적으로 접근할 수 있는 메서드를 통해 제공되는데 getInstance() 와 같은 이름으로 구현되며 클래스의 인스턴스가 존재하지 않을 경우 새로 생성하고, 이미 존재할 경우 기존 인스턴스를 반환합니다.

 

싱글톤의 구현


    public static Data Instance { get; private set; }

    private void Awake()
    {
		    // 해당 스크립트를 적용할 오브젝트를 생성하고 적용하는 것도 가능
		    // GameObject Instance = new GameObject();
        // 파괴되지 않는 오브젝트를 싱글톤으로 설정
        if (Instance == null)
        {
            Instance = this;
            DontDestroyOnLoad(gameObject);
        }
        else
        {
		        // 만약 추가로 생성될 경우 파괴해버림
            Destroy(gameObject);
        }
    }

위 코드는 싱글톤 패턴을 적용하기 위한 기본 코드로 저는 맨 처음 시작될 Scene에 오브젝트를 하나 설치해두고 해당 스크립트를 할당함으로써 사용했지만 초기화 단계에서 오브젝트를 하나 생성하는 것으로 똑같은 효과를 누릴 수 있습니다.

 

이러한 패턴을 데이터 관리 객체로서 만들어두고 사용했기 때문에 프로그램의 효율성과 안정성을 높힐 수 있었습니다.

 

테스트


마지막으로 네 번째는 프로그램을 테스트 하는 방법이었습니다.

 

프로그램이 제대로 실행되는지 기능 단위로 쪼개서 테스트를 수행할 수 있는데 이를 위한 몇가지 방법이 있습니다.

 

  1. 프로젝트의 시작부터 끝까지 테스트가 필요한 기능에 대해 직접 하나하나 눌러보고 실행해보기
  2. 툴에서 제공하는 테스트 패키지를 사용해 테스트를 자동화하기

 

프로젝트의 규모가 작았기 때문에 저는 주로 제가 직접 실행하면서 버그를 찾는 1번 방법으로 진행을 했었지만 규모가 커지고 다루는 데이터의 양이 많아지면 자연스럽게 2번 방법을 택하는 것이 적절한 방법이 될 것이라 생각하며 이는 잘 정리된 블로그가 있어 이를 참고하면 더 좋을 것 같습니다.

 

https://m.blog.naver.com/cdw0424/221938316183

 

유니티(Unity) - 유닛 테스트(Unit Test), Test Framework, Code Coverage (1). 기능 소개

유닛 테스트, 단위 테스트는 코드를 짤 때 만든 하나하나의 기능들이 원하는 대로 잘 작동하는가를 테스트...

blog.naver.com

 

글을 마무리하며


과거 유니티를 활용한 다양한 프로젝트 경험으로 이번 디지털 트윈을 구현하는 것에 굉장히 자신있게 도전했었지만 생각지도 못한 이슈들과 알았던 내용임에도 빠르게 처리하지 못하는 것을 보고 많은 생각이 들었습니다. 앞으로 프로젝트를 진행하는데 있어서 꼼꼼하게 익히고 기록하는 습관을 통해서 더 나은 개발자가 되기 위해 노력해야겠다고 생각하게된 계기가 된 것 같습니다.