This document is about: FUSION 2
SWITCH TO

VR Shared

Level 4

개요

Fusion VR Shared는 VR을 사용한 멀티플레이 게임이나 애플리케이션을 간단하고 빠르게 시작할 수 있는 방법을 보여줍니다.

공유 모드와 호스트/서버 토폴로지 중 어느 것을 선택할지는 게임의 특성에 따라 결정해야 합니다. 이 샘플에서는 공유 모드가 사용되었습니다.

이 샘플의 목적은 VR 리그를 다루는 방법을 명확히 하고, 기본적인 텔레포트 및 물체 잡기 예시를 제공하는 것입니다.

Fusion VR Shared

시작하기 전

  • 이 프로젝트는 유니티 2021.3과 Fusion 2.0으로 개발되었습니다.
  • 샘플을 실행하려면 먼저 PhotonEngine 관리 화면에서 Fusion AppId를 생성한 후, Real Time 설정의 App Id Fusion 필드에 복사하여 붙여 넣습니다 (Fusion 메뉴에서 접근 가능). 그런 다음 Launch 씬을 로드하고 Play를 눌러 실행합니다.

다운로드

버전 릴리즈 일자 다운로드
2.0.0 Apr 18, 2024 Fusion VR Shared 2.0.0 Build 496

입력 처리

Meta Quest

  • 텔레포트: A, B, X, Y 버튼이나 스틱을 눌러 포인터를 표시합니다. 버튼을 놓으면 허용된 타깃으로 텔레포트합니다.
  • 잡기: 먼저 손을 물체에 올리고 컨트롤러의 잡기 버튼을 눌러 물체를 잡습니다.

마우스

프로젝트에는 기본 데스크톱 리그가 포함되어 있어, 마우스를 사용한 기본적인 상호작용이 가능합니다.

  • 이동: 마우스 왼쪽 버튼을 클릭하여 포인터를 표시합니다. 버튼을 놓으면 허용된 타깃으로 텔레포트합니다.
  • 회전: 마우스 오른쪽 버튼을 누르고 마우스를 움직여 시점을 회전시킵니다.
  • 잡기: 물체 위에서 마우스 왼쪽 버튼을 클릭하여 물체를 잡습니다.

연결 관리자

NetworkRunnerConnection Manager 게임 오브젝트에 설치됩니다. Connection Manager는 게임 설정을 구성하고 연결을 시작하는 역할을 합니다.

C#

private async void Start()
{
    // Launch the connection at start
    if (connectOnStart) await Connect();
}

public async Task Connect()
{
    // Create the scene manager if it does not exist
    if (sceneManager == null) sceneManager = gameObject.AddComponent<NetworkSceneManagerDefault>();
    if (onWillConnect != null) onWillConnect.Invoke();

    // Start or join (depends on gamemode) a session with a specific name
    var args = new StartGameArgs()
    {
        GameMode = GameMode.Shared,
        Scene = CurrentSceneInfo(),
        SceneManager = sceneManager
    };

    // Connexion criteria (note: actual project code contains alternative options)
    args.SessionName = roomName;

    await runner.StartGame(args);
}

public virtual NetworkSceneInfo CurrentSceneInfo()
{
    var activeScene = SceneManager.GetActiveScene();
    SceneRef sceneRef = default;

    if (activeScene.buildIndex < 0 || activeScene.buildIndex >= SceneManager.sceneCountInBuildSettings)
    {
        Debug.LogError("Current scene is not part of the build settings");
    }
    else
    {
        sceneRef = SceneRef.FromIndex(activeScene.buildIndex);
    }

    var sceneInfo = new NetworkSceneInfo();
    if (sceneRef.IsValid)
    {
        sceneInfo.AddSceneRef(sceneRef, LoadSceneMode.Single);
    }
    return sceneInfo;
}

INetworkRunnerCallbacks를 구현하면 Fusion의 NetworkRunnerConnection Manager 클래스와 상호작용할 수 있습니다. 이 샘플에서는, 플레이어가 세션에 조인할 때 로컬 사용자 프리팹을 스폰 하기 위해 OnPlayerJoined 콜백이 사용됩니다.

C#

public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
    if (player == runner.LocalPlayer && userPrefab != null)
    {
        // Spawn the user prefab for the local user
        NetworkObject networkPlayerObject = runner.Spawn(userPrefab, position: transform.position, rotation: transform.rotation, player, (runner, obj) => {
        });
    }
}

생성된 플레이어 객체가 연결이 끊어졌을 때 파괴되도록 하려면, 프리팹의 NetworkObject에서 "Shared Mode Settings > Destroy when state authority leaves"를 체크했는지 확인하세요 (또는 "Allow State Authority Override is unchecked"가 해제되었는지 확인하세요).

리그 (Rigs)

개요

몰입형 애플리케이션에서는 리그가 사용자를 나타내는 모든 이동 가능한 부품(보통 두 손, 머리, 플레이 에어리어)을 설명합니다. 플레이어가 텔레포트할 때 개인 공간(플레이 에어리어)이 이동할 수 있습니다.

네트워크 세션에서는 모든 사용자가 네트워크화된 리그로 나타나며, 이 리그의 다양한 부품 위치는 네트워크를 통해 동기화됩니다.

Fusion VR Shared Rigs Logic

여기서 사용자 한 명은 하나의 NetworkObject로 나타내며, 여러 개의 중첩된 NetworkTransform이 각각의 리그 부품에 연결됩니다.

로컬 사용자를 나타내는 네트워크 리그의 경우, 이 리그는 하드웨어 입력에 의해 제어되어야 합니다. 이를 간단하게 처리하기 위해, 네트워크화되지 않은 별도의 리그가 생성되었으며, 이를 "하드웨어 리그"라고 부릅니다. 이 리그는 유니티의 InputDevice API를 사용하여 하드웨어 입력을 수집합니다.

세부 사항

리그 (Rig)

리그를 제어하는 모든 매개변수(공간에서의 위치와 손의 자세)는 RigState 구조체에 포함됩니다.

C#

public struct RigState
{
    public Vector3 playAreaPosition;
    public Quaternion playAreaRotation;
    public Vector3 leftHandPosition;
    public Quaternion leftHandRotation;
    public Vector3 rightHandPosition;
    public Quaternion rightHandRotation;
    public Vector3 headsetPosition;
    public Quaternion headsetRotation;
    public HandCommand leftHandCommand;
    public HandCommand rightHandCommand;
}

HardwareRig 클래스는 요청 시 이 구조체를 업데이트합니다. 이를 위해 다양한 하드웨어 리그 부품에서 입력 매개변수를 수집합니다.

C#

 RigState _rigState = default;
        
public virtual RigState RigState
{
    get
    {
        _rigState.playAreaPosition = transform.position;
        _rigState.playAreaRotation = transform.rotation;
        _rigState.leftHandPosition = leftHand.transform.position;
        _rigState.leftHandRotation = leftHand.transform.rotation;
        _rigState.rightHandPosition = rightHand.transform.position;
        _rigState.rightHandRotation = rightHand.transform.rotation;
        _rigState.headsetPosition = headset.transform.position;
        _rigState.headsetRotation = headset.transform.rotation;
        _rigState.leftHandCommand = leftHand.handCommand;
        _rigState.rightHandCommand = rightHand.handCommand;
        return _rigState;
    }
}

NetworkRig 컴포넌트는 사용자의 프리팹에 위치하며, 로컬 사용자와 연관된 경우 이 입력을 요청한 후, 일치하는 하드웨어 리그 부품에서 오는 입력 데이터를 따라 네트워크화된 리그 부품을 구성합니다.

이 작업은 로컬 사용자 NetworkRig에서만 수행되며, 상태 권한이 있습니다. 이러한 변경 사항이 프록시(다른 플레이어 애플리케이션의 이 플레이어 객체 인스턴스)에서 복제되도록 하기 위해 다른 작업도 필요합니다.

  • 리그 부품의 위치와 회전의 경우, 해당 리그 부품에는 NetworkTransform 컴포넌트가 있으며, 이는 이미 Transform 위치 또는 회전이 업데이트될 때 이 동기화를 처리합니다.
  • 손 자세와 같은 애플리케이션 전용 데이터의 경우, 네트워크화된 변수(즉, [Networked] 태그가 있는 속성)가 설정되며, 값이 변경될 때 콜백이 트리거 되어 새 값을 처리합니다.

C#

// As we are in shared topology, having the StateAuthority means we are the local user
public bool IsLocalNetworkRig => Object && Object.HasStateAuthority;

public override void Spawned()
{
    base.Spawned();
    if (IsLocalNetworkRig)
    {
        hardwareRig = FindObjectOfType<HardwareRig>();
        if (hardwareRig == null) Debug.LogError("Missing HardwareRig in the scene");
    }
}

public override void FixedUpdateNetwork()
{
    base.FixedUpdateNetwork();

    // Update the rig at each network tick for local player. The NetworkTransform will forward this to other players
    if (IsLocalNetworkRig && hardwareRig)
    {
        RigState rigState = hardwareRig.RigState;
        ApplyLocalStateToRigParts(rigState);
        ApplyLocalStateToHandPoses(rigState);
    }
}

protected virtual void ApplyLocalStateToRigParts(RigState rigState)
{
    transform.position = rigState.playAreaPosition;
    transform.rotation = rigState.playAreaRotation;
    leftHand.transform.position = rigState.leftHandPosition;
    leftHand.transform.rotation = rigState.leftHandRotation;
    rightHand.transform.position = rigState.rightHandPosition;
    rightHand.transform.rotation = rigState.rightHandRotation;
    headset.transform.position = rigState.headsetPosition;
    headset.transform.rotation = rigState.headsetRotation;
}

protected virtual void ApplyLocalStateToHandPoses(RigState rigState)
{
    // we update the hand pose info. It will trigger on network hands OnHandCommandChange on all clients, and update the hand representation accordingly
    leftHand.HandCommand = rigState.leftHandCommand;
    rightHand.HandCommand = rigState.rightHandCommand;
}

FixedUpdateNetwork() 동안 네트워크 리그 부품의 위치를 이동시키는 것 외에도, NetworkRig 컴포넌트는 로컬 외삽을 처리합니다. Render() 동안, 이 객체에 대한 권한을 가진 로컬 사용자의 경우, 가장 최근의 로컬 하드웨어 리그 부품 데이터를 사용하여 NetworkTransform이 이동됩니다.

이렇게 하면 로컬 사용자가 자신의 손 위치에 대해 가능한 최신 데이터를 항상 얻을 수 있게 하여, 네트워크 틱 속도가 화면 새로 고침 속도보다 느릴 때에도 불편함을 방지합니다.

[DefaultExecutionOrder(NetworkRig.EXECUTION_ORDER)]public const int EXECUTION_ORDER = 100; 지시어는 NetworkRigRender()NetworkTransform 메소드 뒤에 호출되도록 하여, NetworkRig가 객체의 NetworkTransform을 재정의할 수 있도록 합니다.

C#

public override void Render()
{
    base.Render();
    if (IsLocalNetworkRig)
    {
        // Extrapolate for local user :
        // we want to have the visual at the good position as soon as possible, so we force the visuals to follow the most fresh hardware positions
        RigState rigState = hardwareRig.RigState;

        transform.position = rigState.playAreaPosition;
        transform.rotation = rigState.playAreaRotation;
        leftHand.transform.position = rigState.leftHandPosition;
        leftHand.transform.rotation = rigState.leftHandRotation;
        rightHand.transform.position = rigState.rightHandPosition;
        rightHand.transform.rotation = rigState.rightHandRotation;
        headset.transform.position = rigState.headsetPosition;
        headset.transform.rotation = rigState.headsetRotation;
    }

헤드셋

NetworkHeadset 클래스는 매우 간단합니다. NetworkRig 클래스에서 헤드셋 NetworkTransform에 접근할 수 있도록 합니다.

C#

[DefaultExecutionOrder(NetworkHeadset.EXECUTION_ORDER)]
public class NetworkHeadset : NetworkBehaviour
{
    public const int EXECUTION_ORDER = NetworkRig.EXECUTION_ORDER + 10;

    [HideInInspector]
    public NetworkTransform networkTransform;
    private void Awake()
    {
        if (networkTransform == null) networkTransform = GetComponent<NetworkTransform>();
    }
}

NetworkHeadset 클래스와 마찬가지로, NetworkHand 클래스는 NetworkRig 클래스에서 손의 Network Transform에 접근할 수 있게 해줍니다.

손 자세를 동기화하기 위해, HardwareHand 클래스에 HandCommand라는 네트워크 구조체가 생성되었습니다.

C#

// Structure representing the inputs driving a hand pose 
[System.Serializable]
public struct HandCommand : INetworkStruct
{
    public float thumbTouchedCommand;
    public float indexTouchedCommand;
    public float gripCommand;
    public float triggerCommand;
    // Optionnal commands
    public int poseCommand;
    public float pinchCommand;// Can be computed from triggerCommand by default
}

HandCommand 구조체는 손의 다양한 속성(손 자세 포함)을 설정하는 IHandRepresentation 인터페이스에 사용됩니다. NetworkHand는 자식 IHandRepresentation을 가질 수 있으며, 손 자세 데이터를 이 자식에게 전달합니다.

C#

    public interface IHandRepresentation
    {
        public void SetHandCommand(HandCommand command);
        public GameObject gameObject { get; }
        public void SetHandColor(Color color);
        public void SetHandMaterial(Material material);
        public void DisplayMesh(bool shouldDisplay);
        public bool IsMeshDisplayed { get; }
    }

OSFHandRepresentation 클래스는 각 손에 위치하며, 제공된 손 애니메이터 덕분에 손가락 위치를 수정하기 위해 이 인터페이스를 구현합니다 (ApplyCommand(HandCommand command) 함수).

Fusion VR Shared Hand Representation
Fusion VR Shared Hand Animator

이제 손 자세가 어떻게 동기화되는지 살펴보겠습니다.

HandCommand 구조체는 HardwareHandUpdate()에서 손가락의 위치로 업데이트됩니다.

C#

protected virtual void Update()
{
    // update hand pose
    handCommand.thumbTouchedCommand = thumbAction.action.ReadValue<float>();
    handCommand.indexTouchedCommand = indexAction.action.ReadValue<float>();
    handCommand.gripCommand = gripAction.action.ReadValue<float>();
    handCommand.triggerCommand = triggerAction.action.ReadValue<float>();
    handCommand.poseCommand = handPose;
    handCommand.pinchCommand = 0;
    // update hand interaction
    isGrabbing = grabAction.action.ReadValue<float>() > grabThreshold;
}

NetworkRigFixedUpdateNetwork()에서 손 자세 데이터는 다른 리그 부품과 함께 로컬 사용자에게 업데이트됩니다.

C#

public override void FixedUpdateNetwork()
{
    base.FixedUpdateNetwork();

    // Update the rig at each network tick for local player. The NetworkTransform will forward this to other players
    if (IsLocalNetworkRig && hardwareRig)
    {
        RigState rigState = hardwareRig.RigState;
        ApplyLocalStateToHandPoses(rigState);
        ApplyLocalStateToRigParts(rigState);
    }
}


protected virtual void ApplyLocalStateToHandPoses(RigState rigState)
{
    // we update the hand pose info. It will trigger on network hands OnHandCommandChange on all clients, and update the hand representation accordingly
    leftHand.HandCommand = rigState.leftHandCommand;
    rightHand.HandCommand = rigState.rightHandCommand;
}

NetworkHand 컴포넌트는 사용자의 프리팹에 있는 각 손에 위치하며, 손 표현 업데이트를 관리합니다.

이를 위해 클래스는 HandCommand 네트워크 구조체를 포함하고 있으며, ChangeDetector를 통해 변경을 감지합니다.

C#

[Networked]
public HandCommand HandCommand { get; set; }

ChangeDetector changeDetector;

public override void Spawned()
{
    base.Spawned();
    changeDetector = GetChangeDetector(ChangeDetector.Source.SimulationState);
}

NetworkRig이 리그 부품의 위치를 처리하는 것과 유사하게, NetworkHand는 로컬 플레이어에 대한 외삽을 처리하고, 로컬 하드웨어 손을 사용하여 손 자세를 업데이트합니다.
다른 모든 플레이어에 대해서는 changeDetector가 네트워크 구조체가 변경되었는지 확인하고, 손 표현을 업데이트합니다.

C#

public override void Render()
{
    base.Render();
    if (IsLocalNetworkRig)
    {
        // Extrapolate for local user : we want to have the visual at the good position as soon as possible, so we force the visuals to follow the most fresh hand pose
        UpdateRepresentationWithLocalHardwareState();
    }
    else
    {
        foreach (var changedNetworkedVarName in changeDetector.DetectChanges(this))
        {
            if (changedNetworkedVarName == nameof(HandCommand))
            {
                // Will be called when the local user change the hand pose structure
                // We trigger here the actual animation update
                UpdateHandRepresentationWithNetworkState();
            }
        }
    }
}


// Update the hand representation each time the network structure HandCommand is updated
void UpdateRepresentationWithLocalHardwareState()
{
    if (handRepresentation != null) handRepresentation.SetHandCommand(LocalHardwareHand.handCommand);
}

// Update the hand representation with local hardware HandCommand
void UpdateHandRepresentationWithNetworkState()
{
    if (handRepresentation != null) handRepresentation.SetHandCommand(HandCommand);
}

텔레포트 & 이동

Fusion VR Shared Teleport

RayBeamer 클래스는 각 하드웨어 리그 손에 위치하며, 사용자가 버튼을 누르면 광선을 표시하는 역할을 합니다. 사용자가 버튼을 놓으면, 광선의 타깃이 유효한 경우 이벤트가 트리거 됩니다.

C#

    if (onRelease != null) onRelease.Invoke(lastHitCollider, lastHit);

이 이벤트는 하드웨어 리그에 위치한 Rig Locomotion 클래스에서 리슨 합니다.

C#

    beamer.onRelease.AddListener(OnBeamRelease);

그런 다음 리그 텔레포트 코루틴을 호출합니다…

C#

protected virtual void OnBeamRelease(Collider lastHitCollider, Vector3 position)
{
[...]
    if (ValidLocomotionSurface(lastHitCollider))
    {
        StartCoroutine(rig.FadedTeleport(position));
    }
}

이 코루틴은 하드웨어 리그의 위치를 업데이트하고, 하드웨어 헤드셋에 있는 Fader 컴포넌트가 텔레포트하는 동안 화면을 페이드 인 및 페이드 아웃하도록 요청합니다 (사이버 멀미를 방지하기 위해).

C#

public virtual IEnumerator FadedTeleport(Vector3 position)
{
    if (headset.fader) yield return headset.fader.FadeIn();
    Teleport(position);
    if (headset.fader) yield return headset.fader.WaitBlinkDuration();
    if (headset.fader) yield return headset.fader.FadeOut();
}


public virtual void Teleport(Vector3 position)
{
    Vector3 headsetOffet = headset.transform.position - transform.position;
    headsetOffet.y = 0;
    Vector3 previousPosition = transform.position;
    transform.position = position - headsetOffet;
    if (onTeleport != null) onTeleport.Invoke(previousPosition, transform.position);
}

앞서 설명한 것처럼, 하드웨어 리그 위치의 이러한 수정 사항은 NetworkRigFixedUpdateNetwork() 동안 네트워크를 통해 동기화됩니다.

리그 회전에도 동일한 전략이 적용되며, CheckSnapTurn()이 리그 수정 사항을 트리거 합니다.

C#

IEnumerator Rotate(float angle)
{
    timeStarted = Time.time;
    rotating = true;
    yield return rig.FadedRotate(angle);
    rotating = false;
}

public virtual IEnumerator FadedRotate(float angle)
{
    if (headset.fader) yield return headset.fader.FadeIn();
    Rotate(angle);
    if (headset.fader) yield return headset.fader.WaitBlinkDuration();
    if (headset.fader) yield return headset.fader.FadeOut();
}

public virtual void Rotate(float angle)
{
    transform.RotateAround(headset.transform.position, transform.up, angle);
}

물체 잡기

Fusion VR Shared Grab

개요

이 샘플의 물체 잡기 로직은 두 개의 네트워크화된 컴포넌트, NetworkHandColliderGrabberNetworkHandColliderGrabbable에 기반을 두고 있습니다.

  • NetworkHandColliderGrabber는 하드웨어 손이 그랩 액션을 트리거 할 때 물체를 잡고 놓는 역할을 합니다.
  • NetworkHandColliderGrabbable은 네트워크 변수를 통해 물체 잡기 정보를 네트워크로 동기화하여, 물체가 각 플레이어의 애플리케이션에서 그랩 하는 손을 따라가도록 합니다.

VRShared의 물체 잡기는 물체를 잡는 사용자에게 상태 권한을 전송하는 방식에 기반을 두고 있습니다.
따라서 그랩 가능한 객체의 NetworkObject에서 "Allow State Authority Override"가 체크되어 있고, "Destroy when state authority leaves"가 해제되어 있는지 확인하는 것이 중요합니다 (마지막 그랩버의 연결이 끊어져 객체가 파괴되지 않도록).

참고: 리그 부품 위치와 손 자세 처리가 호스트나 서버 토폴로지에서 하는 것과 매우 유사하지만, 이 샘플의 물체 잡기 방식은 공유 토폴로지에 맞게 최대한 간단하게 구현되어 있습니다.

이 페이지는 네트워크 리그와 강하게 결합된 매우 간단하고 구현이 쉬운 잡기 시스템을 설명합니다. 하드웨어 리그에 더 의존하는 대안 구현은 다음 링크에서 확인할 수 있습니다: VR Shared - 로컬 리그 잡기

세부 사항

Fusion VR Shared Remote Grab grabbing logic

물체 잡기

HardwareHand 클래스는 각 손에 위치하며, 사용자가 그립 버튼을 누를 때마다 isGrabbing bool을 업데이트합니다.
updateGrabWithAction bool은 데스크톱 리그를 지원하기 위해 사용되며, 데스크톱 모드에서는 이 bool을 False로 설정하고, VR 모드에서는 True로 설정해야 합니다.

C#

 protected virtual void Update()
 {
    // update hand pose
    handCommand.thumbTouchedCommand = thumbAction.action.ReadValue<float>();
    handCommand.indexTouchedCommand = indexAction.action.ReadValue<float>();
    handCommand.gripCommand = gripAction.action.ReadValue<float>();
    handCommand.triggerCommand = triggerAction.action.ReadValue<float>();
    handCommand.poseCommand = handPose;
    handCommand.pinchCommand = 0;
    // update hand interaction
    if(updateGrabWithAction) isGrabbing = grabAction.action.ReadValue<float>() > grabThreshold;
}

그랩 가능한 물체와의 충돌을 감지하기 위해 각 네트워크 손에는 간단한 박스 콜라이더가 위치해 있습니다.

그랩 액션을 네트워크에서 동기화하려면, 각 네트워크 손에 NetworkHandColliderGrabber 클래스가 추가됩니다. 충돌이 발생하면 OnTriggerStay(Collider other) 메소드가 호출됩니다.

먼저, 콜라이더는 각 네트워크 손에 위치해 있으므로, 로컬 손과 관련된 충돌만 처리해야 합니다.

C#

// We only trigger grabbing for our local hands
if (!hand.IsLocalNetworkRig || !hand.LocalHardwareHand) return;

다음으로, 이미 물체가 잡혔는지 확인합니다. 간단함을 위해, 이 샘플에서는 다중 그랩을 허용하지 않습니다.

C#

// Exit if an object is already grabbed
if (GrabbedObject != null)
{
    // It is already the grabbed object or another, but we don't allow shared grabbing here
    return;
}

그런 다음 다음 사항을 확인합니다:

  • 충돌한 물체가 그랩 할 수 있는지 (NetworkHandColliderGrabbable 컴포넌트를 가지고 있는지)
  • 사용자가 그립 버튼을 눌렀는지

이 조건이 충족되면, 잡은 물체가 손을 따라가도록 요청됩니다. (다이어그램의 (1) 부분)

C#

NetworkHandColliderGrabbable grabbable;
if (lastCheckedCollider == other)
{
    grabbable = lastCheckColliderGrabbable;
} 
else
{
    grabbable = other.GetComponentInParent<NetworkHandColliderGrabbable>();
}

// To limit the number of GetComponent calls, we cache the latest checked collider grabbable result
lastCheckedCollider = other;
lastCheckColliderGrabbable = grabbable;
if (grabbable != null)
{
    if (hand.LocalHardwareHand.isGrabbing) Grab(grabbable);
} 

물체 잡기 동기화

그랩 상태는 열거형으로 저장됩니다:

C#

enum Status { 
    NotGrabbed,
    Grabbed,
    WillBeGrabbedUponAuthorityReception
    }
Status status = Status.NotGrabbed;

공유 모드를 사용하는 이 샘플에서는 모든 플레이어가 객체에 대해 상태 권한을 요청하고, 그랩 상태를 설명하는 네트워크 변수를 변경할 수 있습니다.
따라서, 플레이어가 객체를 잡으려고 할 때 아직 권한이 없을 수 있습니다.

그래서 NetworkHandColliderGrabbable Grab 메소드는 먼저 상태 권한을 요청한 다음, 현재 그랩버(및 그랩 포인트 오프셋)를 저장합니다.
객체가 잡힌 상태일 때 IsGrabbed가 true로 설정되며, 이때 CurrentGrabber가 설정됩니다.

C#

public async void Grab(NetworkHandColliderGrabber newGrabber)
{
    if (onWillGrab != null) onWillGrab.Invoke(newGrabber);

    // Find grabbable position/rotation in grabber referential
    localPositionOffsetWhileTakingAuthority = newGrabber.transform.InverseTransformPoint(transform.position);
    localRotationOffsetWhileTakingAuthority = Quaternion.Inverse(newGrabber.transform.rotation) * transform.rotation;
    grabberWhileTakingAuthority = newGrabber;

    // Ask and wait to receive the stateAuthority to move the object
    status = Status.WillBeGrabbedUponAuthorityReception;
    isTakingAuthority = true;
    await Object.WaitForStateAuthority();
    isTakingAuthority = false;

    if (status == Status.NotGrabbed)    
        {
        // Object has been already ungrabbed while waiting for state authority
        return;
        }
    if (Object.HasStateAuthority == false)
        {
            Debug.LogError("Unable to receive state authority");
            return;
        }
    status = Status.Grabbed;

    // We waited to have the state authority before setting Networked vars
    LocalPositionOffset = localPositionOffsetWhileTakingAuthority;
    LocalRotationOffset = localRotationOffsetWhileTakingAuthority;

    // Update the CurrentGrabber in order to start following position in the FixedUpdateNetwork
    CurrentGrabber = grabberWhileTakingAuthority;
}

CurrentGrabber, LocalPositionOffset, LocalRotationOffset는 네트워크화된 변수로 선언됩니다.

FixedUpdateNetwork()는 프록시(네트워크 객체에 상태 권한이 없는 원격 플레이어)에서 호출되지 않으므로, 네트워크화된 변수에서 변경을 감지하기 위해 두 개의 ChangeDetector가 필요합니다.
상태 권한이 있는 경우 물체 잡기 로직은 FixedUpdateNetwork()에서 처리되며, 콜백은 모든 사용자에게 Render() 동안 호출됩니다.

헬퍼 메소드 TryDetectGrabberChange()는 두 경우 모두 사용됩니다.

C#


    ChangeDetector funChangeDetector;
    ChangeDetector renderChangeDetector;

    public override void Spawned()
    {
    [...]
        funChangeDetector = GetChangeDetector(NetworkBehaviour.ChangeDetector.Source.SimulationState);
        renderChangeDetector = GetChangeDetector(NetworkBehaviour.ChangeDetector.Source.SnapshotFrom);
    }



    public override void FixedUpdateNetwork()
    {
        // Check if the grabber changed
        if (TryDetectGrabberChange(funChangeDetector, out var previousGrabber, out var currentGrabber))
        {
            if (previousGrabber)
            {
                // Object ungrabbed
                UnlockObjectPhysics();
            }
            if (currentGrabber)
            {
                // Object grabbed
                LockObjectPhysics();
            }
        }
    [...]
    }

    public override void Render()
    {
        // Check if the grabber changed, to trigger callbacks only (actual grabbing logic in handled in FUN for the state authority)
        // Those callbacks can't be called in FUN, as FUN is not called on proxies, while render is called for everybody
        if (TryDetectGrabberChange(renderChangeDetector, out var previousGrabber, out var currentGrabber))
        {
            if (previousGrabber)
            {
                if (onDidUngrab != null) onDidUngrab.Invoke();
            }
            if (currentGrabber)
            {
                if (onDidGrab != null) onDidGrab.Invoke(currentGrabber);
            }
        }
    [...]
    }


    bool TryDetectGrabberChange(ChangeDetector changeDetector, out NetworkHandColliderGrabber previousGrabber, out NetworkHandColliderGrabber currentGrabber)
    {
        previousGrabber = null;
        currentGrabber = null;
        foreach (var changedNetworkedVarName in changeDetector.DetectChanges(this, out var previous, out var current))
        {
            if (changedNetworkedVarName == nameof(CurrentGrabber))
            {
                var grabberReader = GetBehaviourReader<NetworkHandColliderGrabber>(changedNetworkedVarName);
                previousGrabber = grabberReader.Read(previous);
                currentGrabber = grabberReader.Read(current);
                return true;
            }
        }
        return false;
    }

이 코드는 모든 플레이어가 네트워크 변수가 업데이트되면 즉시 새 값을 받을 수 있게 해줍니다 (다이어그램의 (2) 부분).
콜백은 모든 플레이어가 물체 잡기 및 놓기 이벤트에 따라 객체의 상태(주로 키네틱 상태)를 조정하거나 복원할 수 있게 해줍니다 (다이어그램의 (3) 부분).

따라가기 (Follow)

NetworkHandColliderGrabbableFixedUpdateNetwork()에서, 상태 권한을 가진 플레이어가 물체를 잡고 있을 때 그랩 하는 손을 따라 물체의 위치가 업데이트됩니다 (다이어그램의 (4) 부분).
이후 NetworkTransform 컴포넌트가 이를 동기화하여 모든 플레이어에게 위치가 전달되도록 보장합니다.

C#

public override void FixedUpdateNetwork()
{
    [...]

    // We only update the object position if we have the state authority
    if (!Object.HasStateAuthority) return;

    if (!IsGrabbed) return;

    // Follow grabber, adding position/rotation offsets
    Follow(followedTransform: CurrentGrabber.transform, LocalPositionOffset, LocalRotationOffset);}

C#

    void Follow(Transform followedTransform, Vector3 localPositionOffsetToFollowed, Quaternion localRotationOffsetTofollowed)
    {
        transform.position = followedTransform.TransformPoint(localPositionOffsetToFollowed);
        transform.rotation = followedTransform.rotation * localRotationOffsetTofollowed;
    }

렌더

NetworkRigNetworkHand와 유사하게, NetworkHandColliderGrabbable도 네트워크 틱 사이의 최신 위치에서 잡은 물체의 위치를 업데이트하며 외삽을 처리합니다 (다이어그램의 (5) 부분).
여러 [DefaultExecutionOrder]public const int EXECUTION_ORDER 지시어는 NetworkGrabbbleRender()NetworkTransform 메소드 뒤에 호출되어 객체의 시각적 위치를 재정의할 수 있게 합니다.

이 외삽에는 이전과 비교해 두 가지 특이점이 있습니다:

  • 첫째, 외삽은 로컬 사용자에게만 제한되지 않습니다. 물체가 잡혔을 때, 모든 사용자가 잡힌 물체가 손을 따라가야 한다는 것을 "알고" 있습니다 (잡기를 설명하는 네트워크 변수 덕분에). 네트워크 상의 물체와 그랩버의 위치가 약간 동기화되지 않았더라도, 시각적 요소는 일치해야 합니다 (프록시에서 손 주위에 물체가 약간 떠 있는 현상을 방지하기 위해).
  • 둘째, 상태 권한을 받는 동안 외삽을 활성화하는 옵션이 기본적으로 활성화되어 있습니다. 이렇게 하면 상태 권한을 받을 때까지 잡힌 물체가 가만히 있는 것을 방지하여 사용자 경험을 향상시킬 수 있습니다 (매우 짧은 시간일지라도, VR에서는 사용자가 이를 약간 인지할 수 있습니다).
    따라서 상태 권한을 요청하는 동안 그랩버 및 그랩 포인트 위치를 임시 로컬 변수에 저장하여 해당 데이터를 사용해 특정 외삽을 수행합니다.

C#

public override void Render()
{
    [...]

    if (isTakingAuthority && extrapolateWhileTakingAuthority)
    {
        // If we are currently taking the authority on the object due to a grab, the network info are still not set
        //  but we will extrapolate anyway (if the option extrapolateWhileTakingAuthority is true) to avoid having the grabbed object staying still until we receive the authority
        ExtrapolateWhileTakingAuthority();
        return;
    }

    // No need to extrapolate if the object is not grabbed
    if (!IsGrabbed) return;

    // Extrapolation: Make visual representation follow grabber, adding position/rotation offsets
    // We extrapolate for all users: we know that the grabbed object should follow accuratly the grabber, even if the network position might be a bit out of sync
    Follow(followedTransform: CurrentGrabber.hand.transform, LocalPositionOffset, LocalRotationOffset);
}

void ExtrapolateWhileTakingAuthority()
{
    // No need to extrapolate if the object is not really grabbed
    if (grabberWhileTakingAuthority == null) return;

    // Extrapolation: Make visual representation follow grabber, adding position/rotation offsets
    // We use grabberWhileTakingAuthority instead of CurrentGrabber as we are currently waiting for the authority transfer: the network vars are not already set, so we use the temporary versions
    Follow(followedTransform: grabberWhileTakingAuthority.hand.transform, localPositionOffsetWhileTakingAuthority, localRotationOffsetWhileTakingAuthority);
}

물리 그랩을 위한 VR 외삽

VR에서는 비키네틱 물체의 경우, 물체가 물리적으로 계산된 다양한 위치 사이에서 "점프" 하는 현상이 발생할 수 있습니다.

이를 숨기기 위해 외삽이 필요합니다. 다음 두 가지 옵션이 가능합니다:

  1. Fusion이 물리 연산을 담당하는 경우, Render 호출 동안 외삽을 처리합니다.
  2. Fusion이 물리 연산을 처리하지 않는 경우, 유니티의 리지드바디를 구성하여 이 외삽을 추가할 수 있습니다.

1번을 수행하려면, 런너의 게임 오브젝트에 RunnerSimulatePhysics3D를 추가해야 합니다. 주의할 점은, 비네트워크 부모(씬 조직을 위해)를 가진 NetworkRigidBody3D는 올바르게 외삽이 작동하도록 하려면 Sync parent가 체크되어 있어서는 안 됩니다.

2번을 수행하려면, RigidBody 컴포넌트에서 interpolate 옵션을 선택하면 됩니다.

이 샘플에서는 단순화를 위해 두 번째 옵션이 사용되었습니다.

타사 라이브러리

다음 단계

이 프로젝트에서 연습할 수 있는 수정 또는 개선 사항에 대한 몇 가지 제안 사항이 있습니다:

  • 다른 플레이어에게 로컬 텔레포트 레이를 표시합니다 (네트워크 변수를 사용하거나 INetworkInput 사용).
  • 음성 기능을 추가합니다. Photon Voice와 Fusion 통합에 대한 자세한 내용은 이 페이지를 참조하세요: Fusion용 Photon Voice 시작하기
  • 추가 네트워크 객체를 런타임에 스폰 하는 버튼을 만듭니다.
Back to top