카테고리 없음

Oculus Quest2를 위한 Tennis 연습 App 만들기(with Unity)

Wood Pecker 2021. 2. 26. 00:00

오큘러스 퀘스트2 가상현실 세계에서 테니스 스트로크 폼을 연습하는 앱(Virtual Trainer)이 없는 것은 아니다. 테니스 라켓에 컨트롤러를 붙이고 헤드셋을 쓰고 가상의 테니스 볼을 치면서 폼을 연습하는 앱을 구할 수 있다. sidequestvr.com에서 검색하여 찾아 보도록 하자. 현재 아직 베타버전이라 그런지 볼의 스피드도 느리고 뭔가 내가 원하는 기능도 넣고 싶어 나만의 연습앱을 만들어 보도록 하였다. 처음 시작할 때는 3일 정도면 만들겠지 하였지만 의외로 복병이 많이 나타나서 다소 시간이 소요되었고 아직 만족스런 수준은 아니지만 이곳에 정리하여 본다.

유니티(Unity)에서 작업을 하였다. 필요한 3D 모델은 인터넷에서 무료버전을 구하고 불렌더 또는 유니티 내에서 원한는 크기와 모양이 되도록 일부 수정하였다. 피칭머신은 대포모델을 이용하였다. OVRPlayerController를 사용하고 CustomHand를 부착하고 RightHandAnchor 아래에 테니스 prince 라켓모델을 부착한다. 자세한 방법은 이전 포스트 내용을 읽어보자.

Rigidbody와 BoxCollier를 라켓에 부착하여야 하나 RightHandAnchor에도 Rigidbody가있다.그러나 RightHandAnchor에 부착된 Rigidbody를 없앨수 없고 대신 최소기능만 유지하도록 하였다. 즉 무게를 0.0001로 설정하고 Freeze Position과 Freeze Rotation을 모두 채크하여 충돌의 영향을 받지 않도록 하였다. tennis_prince 라켓 모델의 메쉬는 Cylinder03.001에 존재하지만 tennis_prince에 Rigidbody를 의무적으로 부착하여야 한다. 이 또한 최소한의 기능만 유지하도록 한다. tennis_prince 라켓 게임오브젝트는 RightHandAnchor 자식으로 등록되어 있어 상대적 위치를 (0,0,0)로 설정되어 있으나실행을 하면 월드좌표 원점으로 자동 이동되어 위치가 틀어졌다.그래서 런타임애서 강제적으로 상대적 위치를 (0,0,0)로설정하였다.

transform.localPosition = new Vector3(0, 0, 0);

계층적으로 구조에서 여러개의 collider는 실행시에 모델의 메시에도 영향을 주었다. 그레서 테니스 라켓에 아주 작은 cube를 붙이고 이곳에 BoxColider를 붙여 메시가 실행 중에 임의의 크기로 변하지 않도록 하였다. 문제는 공의 스피드가 빨라지면 충돌감지를 채크하지 못하고 볼이 라켓을 통하하는 문제가 있다. 그래서 베타버전의 테니스 볼 속도가 매우 느리었나 보다. 이부분이 제일 맘에 안들어서 직접 짜기로 한 것인데 아마 똑 같은 난제 때문이 아닌가 생각된다. 어째든 공의 속도를 빠르게 설정하고 colider를 많이 키우는 등 많은 시행착오를 하여야 했다. 다음 단계는 컨트롤러의 움직임(곧 라켓스윙)의 가속도 값을 읽어서 공에 힘을 가해주어야 한다. 이곳 또한 시행 착오를 통하여 값을 조절하였다. 그러나 가급적 기본 물리엔진의 설정을 그대로 이용하기로 하였다. 중력가속도 -9.8, 볼의무게 56g, 라켓무게 300g 등이다. 아래의 코드는 캐논에서 볼을 피칭하는 코드이다.

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

public class BallSpawner : MonoBehaviour
{
    public Transform ball;
    public float ballSpeed = 1.20f;
    public float spinSpeed = 1.0f;
    public enum SPIN { nospin, leftspin, rightspin, topspin, bottomspin };
    public SPIN currentSpin = SPIN.topspin;
    float timerInterval = 5.0f;
    private float InstantiationTimer;

    void Start()
    {
        InstantiationTimer = timerInterval;
    }

    void CreatePrefab()
    {
        InstantiationTimer -= Time.deltaTime;
        if (InstantiationTimer <= 0)
        {
            Transform bb = (Transform)Instantiate(ball, transform.position, Quaternion.Euler(0, 0, 0));
            Rigidbody bbRigidbody = bb.GetComponent<Rigidbody>();
            switch (currentSpin)
            {
                case SPIN.topspin:
                    bbRigidbody.AddTorque(Vector3.up * spinSpeed);
                    bbRigidbody.AddForceAtPosition(transform.forward * ballSpeed, transform.position, ForceMode.Impulse);
                    break;
                case SPIN.bottomspin:
                    bbRigidbody.AddTorque(Vector3.down * spinSpeed);
                    bbRigidbody.AddForceAtPosition(transform.forward * ballSpeed, transform.position, ForceMode.Impulse);
                    break;
                case SPIN.leftspin:
                    bbRigidbody.AddTorque(Vector3.left * spinSpeed);
                    bbRigidbody.AddForceAtPosition(transform.forward * ballSpeed, transform.position, ForceMode.Impulse);
                    break;
                case SPIN.rightspin:
                    bbRigidbody.AddTorque(Vector3.right * spinSpeed);
                    bbRigidbody.AddForceAtPosition(transform.forward * ballSpeed, transform.position, ForceMode.Impulse);
                    break;
                default: // no spin
                         // no required torque
                    bbRigidbody.AddForceAtPosition(transform.forward * ballSpeed, transform.position, ForceMode.Impulse);
                    break;
            }
            InstantiationTimer = timerInterval;
        }
    }

    void Update()
    {
        CreatePrefab();
    }
}

테니스 볼(56g)이 라켓과 충돌하면 컨틀로러의 가속도 값을 이용하여 볼에 힘을 가해준다. 컨트롤러의 좌표값과 유니티의 좌표값이 다르므로 가속도의 방향을 유니티 방향으로 변환하여 적용한다. 컨트롤러의 좌표는 아래 그림과 같다.

라켓이 볼에 전달하는 힘은 실제와 다를 수 있으므로 시행착오로 스캘링을 하였으나 완벽하지 않았다. 좀 더 고민하여 볼 사항이다. 볼은 일정 시간 간격으로 캐논에서 생성되어 던져지며 라켓과 충돌이 감지 되면 라켓의 힘을 볼에 전달하여 공의 궤적을 바꾸어준다. 또한 일정 시간 후에는 볼을 화면에서 제거한다. 아래는 볼의 스크립트이다. 이렇게 전체적인 기본 틀을 완성하였으나 아직 불안정하고 자연스럽지 못하다. 좀 더 시간 투자가 필요하다. 마무리는 기회가 되면 다음에 하기로 하고 이 프로그램으로 일단 스윙 연습을 하기로 하였다. 어짜피 Hobby 코딩이니깐 깜끔 마무리는 필요없다. 그냥 불편하면 고치고 필요하면 더 넣으면 된다.

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

public class TennisBall : MonoBehaviour
{
    float endPlayTime = 0.0f;
    public AudioClip clip;
    float lastHitGroundTime = 0.0f;
    static int Count = 0;
    GameObject consoleObj;
    Rigidbody bbRigidbody;
    bool isHit = false;

    void Start()
    {
        endPlayTime = Time.time + 6.0f;
        lastHitGroundTime = Time.time;
        bbRigidbody = transform.GetComponent<Rigidbody>();
    }

    // Update is called once per frame
    void Update()
    {
        if (Time.time > endPlayTime)
        {
           Destroy(transform.gameObject);
        }
    }
    IEnumerator Haptics(float frequency, float amplitude, float duration, bool rightHand, bool leftHand)
    {
        if (rightHand) OVRInput.SetControllerVibration(frequency, amplitude, OVRInput.Controller.RTouch);
        if (leftHand) OVRInput.SetControllerVibration(frequency, amplitude, OVRInput.Controller.LTouch);

        yield return new WaitForSeconds(duration);
        if (rightHand) OVRInput.SetControllerVibration(0, 0, OVRInput.Controller.RTouch);
        if (leftHand) OVRInput.SetControllerVibration(0, 0, OVRInput.Controller.LTouch);

    }

    void OnCollisionEnter(Collision collision)
    {
        AudioSource.PlayClipAtPoint(clip, transform.position); //필요한 곳에 삽입
        if (collision.gameObject.tag == "racket")
        {
            //add racket force
            if (isHit) return;
            isHit = true;
            Vector3  accel= OVRInput.GetLocalControllerAcceleration(OVRInput.Controller.RTouch);
            Vector3 accel2 = accel;
            if (accel.z>0)  accel.z= -accel.z;
            accel.x = -accel.x;
            accel = 0.4f * accel;

            Debug.Log("Racket accel="+accel.ToString());

            Vector3 racketForce = accel * bbRigidbody.mass;//f= ma
            if (bbRigidbody != null)
            {
                bbRigidbody.AddForceAtPosition(racketForce, transform.position, ForceMode.Impulse);
            }

            //test code(Display valuses on HMD Screen)
            Count = Count + 1 ;
            if (consoleObj == null) consoleObj = GameObject.Find("CanvasConsole");
            if (consoleObj == null) return;
            consoleObj.GetComponent<CanvasConsole>().Log("Hit Swing="+Count+"  Racket accel = "+ accel2.ToString());
            //StartCoroutine(Haptics(1, 1, 0.3f, true, false));
        }

        if (collision.gameObject.tag == "earth")
        {
            if(Time.time-lastHitGroundTime<1.4f) Destroy(transform.gameObject);
            lastHitGroundTime = Time.time;
        }
    }
}

반응형