레이블이 l-unity인 게시물을 표시합니다. 모든 게시물 표시
레이블이 l-unity인 게시물을 표시합니다. 모든 게시물 표시

2021년 12월 12일 일요일

[Unity] Unity 2019.3 이상에서 Bridge Header 없이 Swift로 iOS 플러그인 개발하기

개요

  • Unity에서는 iOS 네이티브와의 인터페이스를 Objective-C로만 제공한다. 따라서 Swift로 플러그인을 제작하려면 Objective-C가 Unity와 Swift를 연결해 주어야 한다. Unity 2019.2 버전까지는 Bridge Header를 활용해서 Objective-C와 Swift를 연동할 수 있었다.
  • 그러나 Unity 2019.3 버전에서 Unity as a Library가 도입되면서 플러그인이 Xcode 상에서 기존의 Unity-iPhone 타겟이 아닌 UnityFramework 타겟에 포함되는 것으로 바뀌었다.
  • 문제는 Bridge Header를 Framework 타겟에서 사용할 수 없다는 것이다. 따라서 Swift로 플러그인을 제작하려면 Bridge Header 외의 방법을 활용해야 한다.


참고 자료



개발환경



예제 프로그램

  • UI 버튼이 눌리면 유니티는 네이티브 함수를 호출하여 Swift 함수를 실행한다.
  • Swift 함수는 호출될 때마다 숫자를 1씩 더한 후 해당 숫자를 포함한 문자열을 유니티에 전달한다.
  • 유니티에서는 해당 문자열을 받아서 UI 텍스트를 업데이트한다.


1. Unity에서 네이티브 인터페이스 스크립트 작성

NativeInterface.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Runtime.InteropServices;

public class NativeInterface : MonoBehaviour
{
    public static event System.Action<string> OnNativeCall;

#if !UNITY_EDITOR && UNITY_IOS
    [DllImport("__Internal")]
    private static extern string CallPluginIOS();
#endif

    public static string CallPlugin()
    {
#if UNITY_EDITOR
        return "";
#elif UNITY_IOS
        return CallPluginIOS();
#else
        return "";
#endif
    }

    public void CallUnity(string message)
    {
        OnNativeCall?.Invoke(message);
    }
}

유니티 -> 네이티브 호출

  • [DllImport("__Internal")]를 사용해서 호출할 네이티브 함수를 선언한다. 이 때 함수명은 나중에 Objective-C에 선언할 함수명과 동일해야 한다.
  • 플랫폼에 독립적인 함수 CallPlugin()를 구현한다. Define symbol로 플랫폼을 구분한다. 여기서는 iOS 플랫폼에서 네이티브 함수인 CallPluginIOS()를 호출한다.
  • 여러 플랫폼을 지원하는 소프트웨어를 개발할 때 이 함수와 같이 플랫폼 의존적인 부분을 추상화하는 함수를 만들어 사용하는 것이 좋다.

네이티브 -> 유니티 호출

  • 마지막으로 네이티브에서 호출될 함수로 CallUnity()를 구현한다. 네이티브에서 호출되는 함수이므로, OnNativeCall라는 이벤트를 통해 다른 스크립트에 제공하도록 한다.
  • 네이티브에서 유니티의 함수를 호출하려면 UnitySendMessage()를 사용하는데, 여기에 게임오브젝트 이름과 함수명이 필요하다. 따라서 이  스크립트를 포함하는 게임오브젝트와 함수명을 기억해 두었다가 Objective-C에 작성해야 한다.


2. 네이티브 플러그인 작성 - Objective-C

objc_bridge.h
1
2
3
4
5
@interface ObjcBridge : NSObject

+ (void) sendMessage: (NSString*)message;

@end

objc_bridge.mm
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#import "objc_bridge.h"
#import "UnityFramework/UnityFramework-Swift.h"

extern "C"
{
    const char* CallPluginIOS()
    {
        NSString *ret = [SwiftPlugin callPlugin];
        const char *nsStringUtf8 = [ret UTF8String];
        char* cString = (char*)malloc(strlen(nsStringUtf8) + 1);
        strcpy(cString, nsStringUtf8);
        return cString;
    }

    void unitySendMessage(const char* message)
    {
        UnitySendMessage("NativeInterface", "CallUnity", message);
    }
}

@implementation ObjcBridge
    
+ (void) sendMessage: (NSString*)message
{
    unitySendMessage([message UTF8String]);
}

@end

유니티 -> Objective-C -> Swift

  • 유니티와의 인터페이스 역할을 할 함수들은 extern "C" 안에 선언한다.
  • 유니티에서 호출될 함수는 함수명, 리턴타입, 파라미터를 맞춰서 선언한다. 여기서는 유니티에서 string CallPluginIOS();로 선언하였으므로 리턴 타입을 string에 대응하는 const char*로 한다.
  • 다음으로 Swift 함수를 호출한다. Swift에 선언된 심볼들을 참조하기 위해서는 UnityFramework/UnityFramework-Swift.h를 import해야 한다.
  • Swift 함수에서 리턴된 NSString을 const char* 로 바꾸기 위해 UTF8String 함수를 사용한다.
  • 이 문자열을 그대로 리턴하면 제대로 동작하지 않으므로 새로운 메모리를 할당한 후 거기에 문자열을 복사하여 리턴한다.

Swift -> Objective-C -> 유니티

  • Swift에서 호출할 sendMessage() 함수를 헤더파일과 mm 파일에 선언한다.
  • 이 함수는 extern "C" 안에 선언된 unitySendMessage()를 호출한다.
  • unitySendMessage()는 UnitySendMessage()를 사용하여 유니티의 함수를 호출한다.
    • 이 때 첫 번째 인자로 호출할 함수가 선언된 스크립트가 컴포넌트로 들어있는 게임오브젝트 이름을 전달한다.
    • 두 번째 인자는 호출할 함수 이름을 전달한다.
    • 마지막으로 유니티에 전달하고 싶은 문자열을 전달한다. 이 문자열을 전달하기 위해 sendMessage(), unitySendMessage() 함수는 각각 NSString*과 const char*를 파라미터로 받는다.


3. 네이티브 플러그인 작성 - Swift

SwiftPlugin.swift
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@objc
public class SwiftPlugin: NSObject
{
    static var count: Int = 0;
    
    @objc
    public static func callPlugin() -> String
    {
        count += 1;
        ObjcBridge.sendMessage("count is " + String(count));
        return "Hello, I'm swift.";
    }
}

Objective-C -> Swift

  • Objective-C에서 참조할 심볼들은 모두 @objc 및 public으로 해야 한다.

Swift -> Objective-C

  • Swift에서 Objective-C를 참조하려면 Bridge Header를 쓰지 못하기 때문에 약간 복잡하다. 여기서는 Bridge Header 대신 Umbrella header를  사용할 것이다.
  • Xcode에서 /UnityFramework/UnityFramework.h 파일을 열고 가장 아랫줄에 앞서 작성했던 objc_bridge.h 헤더파일을 import한다.
  • 다음으로 UnityFramework 타겟의 Build Phases 섹션에서 Headers 카테고리의 Public에 objc_bridge.h 헤더파일을 추가한다.
  • 그 다음에는 ObjcBridge 클래스의 sendMessage() 함수를 호출하여 최종적으로 유니티의 함수를 호출한다.


4. 테스트용 스크립트 작성 및 테스트

SampleScript.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class SampleScript : MonoBehaviour
{
    [SerializeField] private Button _button;
    [SerializeField] private Text _test;

    private void Awake()
    {
        _button.onClick.AddListener(delegate
        {
            string ret = NativeInterface.CallPlugin();
            Debug.Log("Call plugin returns: " + ret);
        });

        NativeInterface.OnNativeCall += message =>
        {
            _test.text = message;
        };
    }
}
  • UI 버튼의 onClick 이벤트가 발생하면 네이티브 함수 CallPlugin() 함수를 호출한 후, 그 함수의 리턴값을 로깅한다.
  • 네이티브에서 UnitySendMessage()를 사용하여 NativeInterface.CallUnity() 함수가 호출될 때의 이벤트(NativeInterface.OnNativeCall)로 메시지를 받으면 UI 텍스트에서 해당 메시지를 출력한다.

테스트 영상

  • 로그를 통해 Swift에서 리턴한 메시지도 잘 출력되는 것을 확인할 수 있다.


5. 빌드 스크립트

  • 앞서 진행했던 Umbrella header 관련 작업을 아래와 같은 빌드 스크립트를 통해 자동화할 수 있다.

BuildScript.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEngine;
using UnityEditor;
using UnityEditor.Callbacks;
#if UNITY_IOS
using UnityEditor.iOS.Xcode;
#endif

public static class BuildScript
{
#if UNITY_IOS
    private static string[] publicHeaderPaths = new string[]
    {
        "Libraries/Plugins/iOS/objc_bridge.h",
    };

    [PostProcessBuild]
    public static void OnPostProcessBuild(BuildTarget buildTarget, string buildPath)
    {
        string projPath = buildPath + "/Unity-iPhone.xcodeproj/project.pbxproj";
        PBXProject proj = new PBXProject();
        proj.ReadFromFile(projPath);

        string frameworkTarget = proj.GetUnityFrameworkTargetGuid();

        string unityFrameworkHeaderText = File.ReadAllText(buildPath + "/UnityFramework/UnityFramework.h");
        foreach (string headerPath in publicHeaderPaths)
        {
            string headerGuid = proj.FindFileGuidByProjectPath(headerPath);
            proj.AddPublicHeaderToBuild(frameworkTarget, headerGuid);

            string importStatement = "#import \"" + Path.GetFileName(headerPath) + "\"";
            if (!unityFrameworkHeaderText.Contains(importStatement))
                unityFrameworkHeaderText += "\n" + importStatement + "\n";
        }
        File.WriteAllText(buildPath + "/UnityFramework/UnityFramework.h", unityFrameworkHeaderText);

        proj.WriteToFile(projPath);
    }
#endif
}

  • 22-26 line: Xcode 프로젝트 파일을 읽어온다.
  • 28 line: /UnityFramework/UnityFramework.h 파일을 열어서 텍스트를 읽어온다.
  • 31-32 line: 헤더파일을 Build Phases에 public으로 등록한다.
  • 34-36 line: /UnityFramework/UnityFramework.h 파일에 헤더파일을 import한다.
  • 38-40 line: 프로젝트 파일에 데이터를 쓴다.

활용법

  • 이제 빌드 시마다 추가로 해야 하는 작업은 없다.
  • Swift에서 참조할 헤더파일이 추가될 경우 publicHeaderPaths 배열에 헤더파일의 경로를 추가하기만 하면 된다.

2017년 10월 25일 수요일

[VR] Unity : Bluetooth Controller를 사용하여 캐릭터 컨트롤하기

이번엔 지난 글과 저번 글의 내용을 합쳐서 Bluetooth Controller를 통해 1인칭 캐릭터를 컨트롤할 수 있는 VR앱을 만들 것이다.

배경까지 다 만들기는 귀찮으니까 Assets Store에서 괜찮은 꽁짜 프로젝트를 받아온다.
- 2018년 11월 현재 위 프로젝트는 서비스되지 않아서 받아올 수 없다. 다른 프로젝트를 사용해도 무방하니 적당한 것을 찾아보자


필요없는 부분은 다 지우고 먼저 1인칭 캐릭터 역할을 할 GameObject를 적당한 위치에 적당한 크기로 하나 만든다.



벽을(심지어 바닥도!) 뚫고 다니면 안 되니까 Capsule Collider를 추가하고 물리엔진과 상호작용(중력이라던가 다른 물체와 충돌이라던가)을 할 수 있도록 Rigidbody를 추가한다.
중요한게 Rigidbody에서 Constraints안에 Freeze Rotation의 모든 축을 체크해야 한다. 물리엔진에 의해 캐릭터가 회전하지 않도록 제약을 걸어 두는 것으로, 이거 안 해주면 Capsule Collider의 곡면 때문에 캐릭터가 자기 맘대로 막 굴러다닌다.

그 다음엔 캐릭터 GameObject의 하위 Object로 포함되도록 Camera를 추가한다.



위치는 적당히 잡아주면 된다.
단, 비대칭을 좋아하는 사람이 아니면 X와 Z 좌표는 0으로 해 두자.

이제 스크립트를 하나 추가한다. 이거 참고해서 작성했다.

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

[RequireComponent (typeof (Rigidbody))]
[RequireComponent (typeof (CapsuleCollider))]

public class BlueControlManager : MonoBehaviour
{
  private GameObject cameraObject;
  private Rigidbody CharacterRigidbody;

  private bool jumpPushed = false;

  // contants
  private float speed = 2.2f;
  private float gravity = 10.0f;
  private bool grounded = false;

  private float maxVelocityChange = 1.5f;

  private float jumpHeight = 0.5f;
  private Vector3 jumpVelocity;

  void Start()
  {
  cameraObject = GameObject.Find("Camera");
  CharacterRigidbody = GetComponent<Rigidbody>();

  // From the jump height and gravity we deduce the upwards speed 
  // for the character to reach at the apex.
  jumpVelocity = new Vector3(0, Mathf.Sqrt(2 * jumpHeight * gravity), 0);
  }

  void Update()
  {
  if(Input.GetKeyDown(KeyCode.JoystickButton0))
  jumpPushed = true;
  }

  void FixedUpdate()
  {
  if(grounded)
  {
  // get joystick data
  Vector2 joystickPosition = new Vector2();
  joystickPosition.x = Input.GetAxis("Horizontal");
  joystickPosition.y = Input.GetAxis("Vertical");
  joystickPosition = joystickPosition.normalized;

  // Calculate how fast we should be moving
  Vector3 targetVelocity = new Vector3(0, 0, 0);
  Vector3 forward = new Vector3(0, 0, 0);
  Vector3 right = new Vector3(0, 0, 0);

  forward.x = cameraObject.transform.forward.x;
  forward.z = cameraObject.transform.forward.z;
  right.x = cameraObject.transform.right.x;
  right.z = cameraObject.transform.right.z;
  forward = forward.normalized;
  right = right.normalized;
  targetVelocity = forward * joystickPosition.y + right * joystickPosition.x;
  targetVelocity *= speed;

  // Apply a force that attempts to reach our target velocity
  Vector3 velocity = CharacterRigidbody.velocity;
  Vector3 velocityChange = (targetVelocity - velocity);
  velocityChange.x = Mathf.Clamp(velocityChange.x,
                -maxVelocityChange, maxVelocityChange);
  velocityChange.z = Mathf.Clamp(velocityChange.z,
                -maxVelocityChange, maxVelocityChange);
  velocityChange.y = 0;
  CharacterRigidbody.AddForce(velocityChange, ForceMode.VelocityChange);

  // Jump
  if(jumpPushed)
  CharacterRigidbody.AddForce(jumpVelocity, ForceMode.VelocityChange);
  }

  grounded = false;
  jumpPushed = false;
  }

  void OnCollisionStay(Collision collisionInfo)
  {
  foreach(ContactPoint contact in collisionInfo.contacts)
  {
  if(contact.normal.y > 0.7f)
  {
  grounded = true;
  break;
  }
  }
  }
}

주요 특징

1. UI 처리는 Update() 함수에서, 물리적 처리는 FixedUpdate() 함수에서 한다.
Update() 함수는 프레임마다 한 번 호출되며, 초당 프레임의 변화에 따라 호출 주기가 달라지는 반면 FixedUpdate() 함수는 호출 주기가 일정하다. Input.GetKeyDown() 함수는 버튼이 눌린 시점의 프레임에서만 true를 반환한다. 따라서 FixedUpdate() 함수에서 Input.GetKeyDown() 함수로 값을 읽어오는 경우 프레임 타이밍에 따라 버튼을 눌러도 true값을 읽지 못할 수도 있다. 반면에 물리적 처리는 호출 주기가 일정한 FixedUpdate() 함수에서 처리해야 자연스럽게 동작하며 그렇지 않고 Update() 함수에서 처리할 경우 초당 프레임 변화에 따라 다르게 동작할 것이다.

2. 캐릭터를 움직일 때는 rigidbody의 AddForce() 함수 사용
캐릭터를 움직이는 방법, 즉 캐릭터의 위치를 바꾸는 방법은 위치 자체를 바꾸는 방법, 속도를 바꾸는 방법, 힘(가속도)를 주는 방법의 세 가지가 있다. 위치 자체를 바꾸는 것은 캐릭터가 순간이동하는 것으로 구현될 것이며, 속도를 바꾸거나 힘을 주어야 위치가 연속적으로 변할 것이다. 힘을 주는 방법이 위치와 속도가 연속적으로 변하는 것이므로 가장 자연스럽게 동작할 것이다.

3.  grounded (캐릭터가 땅을 밟고 서 있는지) 판단 조건
캐릭터의 Collider가 다른 Collider와 접촉할 때 호출되는 OnCollisionStay() 함수에서 항상 grounded를 true로 바꾸도록 하면 벽과 접촉할 때에도 땅을 밟고 있다고 판단하여 벽타기가 가능하다.. 따라서 OnCollisionStay() 함수의 인자로 주어지는 Collision 정보 내에서 다른 Collider와 접촉한 방향이 옆쪽이 아닌 아래쪽 방향이 있는 경우에만 땅을 밟고 있다고 판단하게 한다.



플레이 영상!

2017년 10월 24일 화요일

[VR] Unity VR Support

VR 앱을 개발하기 위한 기본적인 것들..
화면을 반으로 나누고, 각 화면의 시점(카메라)을 다르게 해서 3D로 보이도록 만들며, 기기의 센서 정보를 이용해 그에 맞게 시점을 회전하는 head tracking은 VR앱이라면 기본적으로 갖춰야 하는 기능이다.

예전에 처음 Unity를 사용해 VR 앱을 개발할 때에는 센서 값을 읽어오는 플러그인을 넣고, 카메라를 두 개 만든 다음....... 센서 값에 따라 카메라를 회전하는걸 직접 구현했었는데,, 무려 지금은 Unity에서 자동으로 해 준다.
심지어 카드보드, 데이드림, 오큘러스 등등의 VR 플랫폼 지원까지 해준다.
관련 내용은 문서에 잘 나와 있다.

언리얼 엔진도 무료로 풀렸으니 나중엔 이거도 사용해 봐야겠다.
엔진 개발자님들 만쉐이




이번 글은 Unity에서 다 해주므로.. 분량이 없다. Edit -> Project Settings -> Player에 들어가서 원하는 플랫폼에 대해 Virtual Reality Supported에 체크한방 날리고 카드보드인지 뭔지 사용할 VR 플랫폼을 선택하면 끝이다.



알아서 화면도 나눠주고 렌즈를 통해 보기 편하도록 화면 왜곡도 해 주며 head tracking도 된다!


카드보드를 선택하면 안드로이드 4.4 Kit Kat (API level 19) 부터, 데이드림을 선택하면 안드로이드 7.0 Nougat (API level 24) 부터 앱을 실행할 수 있다.

음.. 사실 데이드림은 Daydream Ready로 인증받은 기기에서만 실행이 가능하다. 자세한 내용은 여기 참조

지금은 그냥 조용히 카드보드를 선택하자.

2017년 10월 18일 수요일

[VR] Unity에서 Bluetooth Controller 제어

VR 앱을 개발하기 위해 아이페가 PG-9068 TOMAHAWK 모델의 블루투스 컨트롤러를 구입하였다.


집에 도착한 모습!



Xbox 컨트롤러와 유사한 구조를 갖고 있다.

Unity에서 컨트롤러의 입력값을 읽어오기 위해서는 각 버튼이 어떻게 매핑되어 있는지 알아야 한다.
직접 테스트해본 결과 다음 그림과 같이 매핑되어 있었다.


OS와 컨트롤러에 따라 매핑 정보는 달라질 수 있다. 따라서 출시되는 VR 앱에는 이를 잘 파악하여 동적으로 매핑 정보를 파악할 수 있도록 해야 한다.

그림에서 "JoystickButton*"로 매핑되는 버튼은 Digital 값이고, "*th axis"로 매핑되는 조이스틱이나 버튼은 Analog 값이다. Digital인가 Analog인가에 따라 Unity에서 값을 읽어오는 방식이 다르다.
(LT, RT 버튼은 Digital과 Analog 방식 모두 동작한다.)

먼저 Digital 값은 스크립트에서 다음과 같이 쉽게 읽어올 수 있다.

bool key_value = Input.GetKey(KeyCode.JoystickButton0);

Unity reference를 확인해 보면 KeyCode의 다양한 값을 볼 수 있다. 최대 8개까지의 조이스틱(컨트롤러)를 구분할 수 있으며 각 컨트롤러마다 최대 20개의 버튼을 구분할 수 있다.
이 값을 Input.GetKey() 함수의 인자로 주면 버튼 값을 True나 False로 돌려 준다.
물론 Input.GetKeyDown() 이나 Input.GetKeyUp() 등의 다른 함수도 사용할 수 있다.

Analog 값은 조금 복잡한데, 우선 스크립트에서 읽는 방법은 다음과 같다.
-1.0 ~ 1.0 범위의 값을 얻을 수 있다.

float key_value = Input.GetAxis("Horizontal");

Input.GetAxis() 함수를 사용하는데, 인자로 주는 string은 읽으려는 axis의 이름이다. Unity는 기본적으로 컨트롤러의 첫 번째 조이스틱에 "Horizontal", "Vertical" axis가 매핑되어 있다. 이러한 정보는 Edit -> Project Settings -> Input에서 설정 가능하다.


설정 창을 열게 되면 Inspector에 다음과 같이 InputManager가 보일 것이다.


다양한 Axis 이름들이 정의되어 있는데, 그 중에서 "Horizontal", "Vertical" axis가 미리 정의되어 있는 것을 볼 수 있다. 또 다른 Analog 입력을 추가하고 싶다면 InputManager의 가장 위에 있는 속성인 Size 값을 늘린 후에 추가된 항목에 값을 채워넣으면 된다.

여기서는 다음과 같이 오른쪽 조이스틱을 입력으로 추가했다.


각 속성에 대한 상세한 내용은 여기 참조

가장 위에 있는 Name 속성에 입력하는 string을 스크립트에서 Input.GetAxis() 등의 함수에 인자로 사용하여 해당 Analog 입력 값을 받아올 수 있다.
Axis 속성에는 앞서 언급했던, 버튼과 조이스틱이 매핑된 axis를 선택하면 된다. 오른쪽 조이스틱은 X 축이 3rd axis에, Y 축이 4th axis에 매핑되어 있다고 했으므로 그에 맞게 설정하였다.