VR Host
개요
Fusion VR Host는 VR을 사용하여 멀티플레이어 게임이나 애플리케이션을 빠르고 쉽게 시작하는 방법을 보여줍니다.
공유 또는 호스트/서버 토폴로지 중에서 선택하는 것은 게임의 특성에 따라 달라집니다. 이 샘플에서는 호스트 모드가 사용되었습니다.
이 샘플의 목적은 VR 리그를 처리하는 방법을 명확히 하고, 기본적인 텔레포트 및 그랩 예시를 제공하는 것입니다.
시작하기 전에
- 이 프로젝트는 유니티 2021.3 및 Fusion 2로 개발되었습니다.
- 샘플을 실행하려면 먼저 PhotonEngine 관리 화면에서 Fusion AppId를 생성하고, 이를 Real Time 설정의
App Id Fusion
필드에 붙여 넣으세요. 그런 다음Launch
씬을 로드하고Play
를 누르세요.
다운로드
버전 | 릴리즈 일자 | 다운로드 | |
---|---|---|---|
2.0.1 | Jul 31, 2024 | Fusion VR Host 2.0.1 Build 609 |
입력 처리
Meta Quest
- 텔레포트: A, B, X, Y 버튼 또는 스틱을 눌러 포인터를 표시합니다. 버튼을 놓으면 허용된 대상에 텔레포트됩니다.
- 그랩: 손을 물체 위로 가져가 컨트롤러의 그랩 버튼으로 물체를 잡습니다.
마우스
기본 데스크톱 리그가 프로젝트에 포함되어 있으며, 이를 통해 마우스로 기본적인 상호작용이 가능합니다.
- 이동: 마우스 왼쪽 클릭으로 포인터를 표시하고, 허용된 대상에 텔레포트됩니다.
- 회전: 마우스 오른쪽 버튼을 누른 상태에서 마우스를 움직여 시점을 회전합니다.
- 그랩: 마우스로 물체를 클릭하여 잡습니다.
연결 관리자
NetworkRunner
는 Connection 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 NetworkRunner
가 Connection Manager
클래스와 상호작용할 수 있습니다.
이 샘플에서는 OnPlayerJoined
콜백을 사용하여 플레이어가 세션에 참여하면 호스트가 사용자 프리팹을 생성하고, OnPlayerLeft
를 통해 플레이어가 세션을 떠날 때 이를 제거합니다.
C#
public void OnPlayerJoined(NetworkRunner runner, PlayerRef player)
{
// The user's prefab has to be spawned by the host
if (runner.IsServer && userPrefab != null)
{
Debug.Log($"OnPlayerJoined. PlayerId: {player.PlayerId}");
// We make sure to give the input authority to the connecting player for their user's object
NetworkObject networkPlayerObject = runner.Spawn(userPrefab, position: transform.position, rotation: transform.rotation, inputAuthority: player, (runner, obj) => {
});
// Keep track of the player avatars so we can remove it when they disconnect
_spawnedUsers.Add(player, networkPlayerObject);
}
}
public void OnPlayerLeft(NetworkRunner runner, PlayerRef player)
{
// Find and remove the players avatar (only the host would have stored the spawned game object)
if (_spawnedUsers.TryGetValue(player, out NetworkObject networkObject))
{
runner.Despawn(networkObject);
_spawnedUsers.Remove(player);
}
}
유니티 Connection Manager
게임 오브젝트에서 "Auto Host 또는 Client"가 선택되었는지 확인하세요.
테스트 중 특정 역할을 확인하고 싶다면 "Host" 또는 "Client"를 선택하는 것도 가능합니다.
리그
개요
몰입형 애플리케이션에서 리그는 사용자에게 필요한 모든 이동 가능한 부분을 설명합니다. 일반적으로 양손, 머리, 그리고 사용자가 텔레포트할 때 이동할 수 있는 플레이 에어리어(개인 공간)를 포함합니다.
네트워크 세션에서는 각 사용자가 네트워크 리그로 표현되며, 이 리그의 각 부분 위치는 네트워크를 통해 동기화됩니다.
여러 가지 아키텍처가 가능하며, 리그 부품이 어떻게 구성되고 동기화되는지에 따라 유효합니다. 여기서 사용자는 단일 NetworkObject
로 표현되며, 각 리그 부분마다 여러 중첩된 NetworkTransforms
을 가집니다.
로컬 사용자를 나타내는 네트워크 리그는 하드웨어 입력에 의해 제어됩니다. 이 과정을 간소화하기 위해 네트워크가 없는 별도의 "Hardware rig
"가 생성되었습니다.
이 리그는 유니티의 InputDevice API를 사용해 하드웨어 입력을 수집합니다.
세부 사항
리그
리그를 제어하는 모든 매개변수(공간에서의 위치와 손의 포즈)는 RigInput
구조체에 포함됩니다.
그랩 한 물체에 대한 정보도 이 구조체에 포함됩니다.
C#
public struct RigInput : INetworkInput
{
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;
public GrabInfo leftGrabInfo;
public GrabInfo rightGrabInfo;
}
HardwareRig
클래스는 Fusion NetworkRunner
가 사용자 입력을 폴링 할 때 이 구조체를 업데이트합니다. 이를 위해 다양한 하드웨어 리그 부품에서 입력 매개변수를 수집합니다.
C#
public void OnInput(NetworkRunner runner, NetworkInput input)
{
RigInput rigInput = new RigInput();
rigInput.playAreaPosition = transform.position;
rigInput.playAreaRotation = transform.rotation;
rigInput.leftHandPosition = leftHand.transform.position;
rigInput.leftHandRotation = leftHand.transform.rotation;
rigInput.rightHandPosition = rightHand.transform.position;
rigInput.rightHandRotation = rightHand.transform.rotation;
rigInput.headsetPosition = headset.transform.position;
rigInput.headsetRotation = headset.transform.rotation;
rigInput.leftHandCommand = leftHand.handCommand;
rigInput.rightHandCommand = rightHand.handCommand;
rigInput.leftGrabInfo = leftHand.grabber.GrabInfo;
rigInput.rightGrabInfo = rightHand.grabber.GrabInfo;
input.Set(rigInput);
}
그 후, 해당 입력을 보낸 사용자와 연결된 네트워크 리그가 이를 수신합니다. 호스트(상태 권한을 가진 주체)와 입력을 보낸 사용자(입력 권한을 가진 주체) 모두가 이를 수신하며,
다른 사용자들은 수신하지 않습니다(이들은 프록시 역할을 합니다).
이 과정은 사용자 프리팹에 위치한 NetworkRig
컴포넌트에서 FixedUpdateNetwork()
(FUN) 동안 GetInput
을 통해 이루어집니다(GetInput
은 상태 및 입력 권한이 있는 사용자만 입력을 반환합니다).
FUN 동안, 각 네트워크 리그 부분은 해당 하드웨어 리그의 입력 매개변수를 단순히 따르도록 설정됩니다.
호스트 모드에서는 호스트가 입력을 처리한 후, 이를 프록시에게 전달하여 다른 사용자들의 움직임을 복제할 수 있습니다. 이는 다음 두 가지 방식으로 처리됩니다:
[Networked]
변수를 통해 처리(손 포즈 및 그랩 정보): 상태 권한 소유자(호스트)가 네트워크 변수를 변경하면 해당 값이 각 사용자에게 복제됩니다.- 위치와 회전과 관련해서는 상태 권한 소유자의
NetworkTransform
컴포넌트가 이를 처리하여 다른 사용자에게 복제합니다.
C#
// As we are in host topology, we use the input authority to track which player is the local user
public bool IsLocalNetworkRig => Object.HasInputAuthority;
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
if (GetInput<RigInput>(out var input))
{
transform.position = input.playAreaPosition;
transform.rotation = input.playAreaRotation;
leftHand.transform.position = input.leftHandPosition;
leftHand.transform.rotation = input.leftHandRotation;
rightHand.transform.position = input.rightHandPosition;
rightHand.transform.rotation = input.rightHandRotation;
headset.transform.position = input.headsetPosition;
headset.transform.rotation = input.headsetRotation;
// we update the hand pose info. It will trigger on network hands OnHandCommandChange on all clients, and update the hand representation accordingly
leftHand.HandCommand = input.leftHandCommand;
rightHand.HandCommand = input.rightHandCommand;
leftGrabber.GrabInfo = input.leftGrabInfo;
rightGrabber.GrabInfo = input.rightGrabInfo;
}
}
FixedUpdateNetwork()
동안 네트워크 리그의 부품 위치를 이동시키는 것 외에도, NetworkRig
컴포넌트는 로컬 추정(extrapolation)도 처리합니다. Render()
동안 로컬 하드웨어 리그에서 얻은 최신 데이터를 사용하여 리그 부품이 이동되며, 이는 입력 권한을 가진 로컬 사용자에 대해서만 적용됩니다.
이를 통해 로컬 사용자가 자신의 손 위치를 항상 가장 최신 상태로 유지할 수 있도록 하여, 화면 새로 고침 속도가 네트워크 틱 속도보다 높더라도 불편함을 느끼지 않도록 보장합니다.
클래스 앞에 있는 [DefaultExecutionOrder(NetworkRig.EXECUTION_ORDER)]
태그(EXECUTION_ORDER = 100
)는 NetworkRig
의 Render()
가 NetworkTransform
메소드 후에 호출되도록 보장하여, NetworkRig
가 원래 처리 방식을 재정의할 수 있도록 합니다.
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
// To update the visual object, and not the actual networked position, we move the interpolation targets
transform.position = hardwareRig.transform.position;
transform.rotation = hardwareRig.transform.rotation;
leftHand.transform.position = hardwareRig.leftHand.transform.position;
leftHand.transform.rotation = hardwareRig.leftHand.transform.rotation;
rightHand.transform.position = hardwareRig.rightHand.transform.position;
rightHand.transform.rotation = hardwareRig.rightHand.transform.rotation;
headset.transform.position = hardwareRig.headset.transform.position;
headset.transform.rotation = hardwareRig.headset.transform.rotation;
}
}
헤드셋
NetworkHeadset
클래스는 매우 간단합니다. 이 클래스는 NetworkRig
클래스에서 사용할 수 있는 헤드셋의 NetworkTransform
에 접근할 수 있도록 해줍니다.
C#
public class NetworkHeadset : NetworkBehaviour
{
[HideInInspector]
public NetworkTransform networkTransform;
private void Awake()
{
if (networkTransform == null) networkTransform = GetComponent<NetworkTransform>();
}
}
손
NetworkHeadset
클래스와 마찬가지로, NetworkHand
클래스는 NetworkRig
클래스에서 사용할 수 있도록 손의 NetworkTransform
에 접근할 수 있도록 합니다.
손의 포즈를 동기화하기 위해, 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; }
public Material SharedHandMaterial { get; }
}
OSFHandRepresentation
클래스는 각 손에 위치하며, 제공된 손 애니메이터를 사용해 손가락 위치를 수정하는 방식으로 이 인터페이스를 구현합니다 (ApplyCommand(HandCommand command)
함수).
이제 손 포즈가 어떻게 동기화되는지 살펴보겠습니다.
HandCommand
구조체는 HardwareHand
의 Update()
에서 손가락의 위치와 함께 업데이트됩니다.
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;
if (localHandRepresentation != null) localHandRepresentation.SetHandCommand(handCommand);
}
각 NetworkRig
의 FixedUpdateNetwork()
에서 손 포즈 데이터는 로컬 사용자와 함께 다른 리그 입력과 함께 업데이트됩니다.
C#
public override void FixedUpdateNetwork()
{
base.FixedUpdateNetwork();
// update the rig at each network tick
if (GetInput<RigInput>(out var input))
{
transform.position = input.playAreaPosition;
transform.rotation = input.playAreaRotation;
leftHand.transform.position = input.leftHandPosition;
leftHand.transform.rotation = input.leftHandRotation;
rightHand.transform.position = input.rightHandPosition;
rightHand.transform.rotation = input.rightHandRotation;
headset.transform.position = input.headsetPosition;
headset.transform.rotation = input.headsetRotation;
// we update the hand pose info. It will trigger on network hands OnHandCommandChange on each client, and update the hand representation accordingly
leftHand.HandCommand = input.leftHandCommand;
rightHand.HandCommand = input.rightHandCommand;
leftGrabber.GrabInfo = input.leftGrabInfo;
rightGrabber.GrabInfo = input.rightGrabInfo;
}
}
NetworkHand
컴포넌트는 사용자 프리팹의 각 손에 위치하며, 손의 표현을 업데이트하는 역할을 담당합니다.
이를 위해 HandCommand
라는 네트워크 구조체를 사용합니다.
ChangeDetector
를 통해 상태 권한(호스트)이 네트워크 구조체를 변경할 때마다 UpdateHandRepresentationWithNetworkState()
를 호출하여 각 플레이어의 손 표현을 업데이트합니다.
C#
[Networked]
public HandCommand HandCommand { get; set; }
ChangeDetector changeDetector;
public override void Render()
{
base.Render();
if (IsLocalNetworkRig)
{
...
}
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 UpdateHandRepresentationWithNetworkState()
{
if (handRepresentation != null) handRepresentation.SetHandCommand(HandCommand);
}
NetworkRig
가 리그 부품의 위치를 처리하는 것과 유사하게, Render()
동안 NetworkHand
도 로컬 하드웨어 손을 사용하여 손 포즈의 추정 및 업데이트를 처리합니다.
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
{
...
}
}
// Update the hand representation with the local hardware state
void UpdateRepresentationWithLocalHardwareState()
{
if (handRepresentation != null) handRepresentation.SetHandCommand(LocalHardwareHand.handCommand);
}
텔레포트 및 이동
각 하드웨어 리그 손에 위치한 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;
transform.position = position - headsetOffet;
}
앞서 설명한 것처럼, 하드웨어 리그의 위치 변경은 OnInput
콜백 덕분에 네트워크를 통해 동기화됩니다.
같은 전략이 리그 회전에 적용되며, 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);
}
그랩
개요
여기서 그랩 로직은 두 부분으로 나누어집니다:
- 로컬 부분: 네트워크가 없는 상태에서 하드웨어 손이 그랩 동작을 트리거 했을 때 실제로 그랩 및 해제되는 부분을 감지합니다. (
Grabber
및Grabbable
클래스) - 네트워크 부분: 모든 플레이어가 그랩 상태를 알 수 있도록 보장하며, 그랩 한 손을 따라 실제 위치 변경을 관리합니다. (
NetworkGrabber
및NetworkGrabbable
클래스)
참고: 로컬 부분은 오프라인 모드에서 자체적으로 움직임을 관리할 수 있도록 하는 몇 줄의 코드가 포함되어 있습니다. 그러나 이 문서는 실제 네트워크 사용에 초점을 맞춥니다.
이 샘플에서는 두 가지 다른 그랩 유형이 제공됩니다 (Grabbable
및 NetworkGrabbable
클래스는 추상 클래스이며, 각각의 논리를 구현하는 서브 클래스가 존재합니다):
- 키네마틱 객체에 대한 그랩: 이들은 그랩 한 손의 위치를 단순히 따릅니다. 다른 객체와 물리적 상호작용은 하지 않습니다.
KinematicGrabbable
및NetworkKinematicGrabbable
클래스에서 구현됩니다. - 물리적 객체에 대한 그랩: 이들의 속도는 그랩 한 손을 따르도록 변경됩니다. 다른 객체와 물리적 상호작용을 하며, 던질 수도 있습니다.
PhysicsGrabbable
및NetworkPhysicsGrabbable
클래스에서 구현됩니다.
참고: 키네마틱 객체에 대해 해제 속도를 부여해 던질 수 있지만, 이 샘플에서는 단순한 그랩 예시를 보여주기 위한 것이라 추가적인 코드는 포함되지 않았습니다. 물리적 그랩은 더 논리적이고 정확한 구현을 제공하므로 이 예시에서는 그 방법을 제시합니다.
그랩 트리거 및 전환
각 손에 위치한 HardwareHand
클래스는 매 업데이트마다 isGrabbing
불리언 값을 업데이트합니다. 사용자가 그립 버튼을 누르면 이 값이 true
로 설정됩니다.
참고로, updateGrabWithAction
불리언 값은 데스크톱 리그를 지원하기 위해 사용됩니다. 이 값은 마우스와 키보드로 제어되는 리그의 경우 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;
if (localHandRepresentation != null) localHandRepresentation.SetHandCommand(handCommand);
}
그랩 가능한 객체와의 충돌을 감지하기 위해, 각 하드웨어 손에 간단한 박스 콜라이더가 있으며, 이 콜라이더는 해당 손에 배치된 Grabber
컴포넌트에 의해 사용됩니다. 충돌이 발생하면 OnTriggerStay()
메소드가 호출됩니다.
호스트 토폴로지에서는 일부 틱이 새로운 틱(실제 새 틱)이 되고, 다른 틱은 리시뮬레이션(과거 재생)일 수 있습니다. 그랩 및 언그랩은 현재 위치와 일치하는 포워드 틱 동안에만 감지되어야 합니다. 따라서 OnTriggerStay()
는 리시뮬레이션 틱 동안에는 실행되지 않습니다.
C#
private void OnTriggerStay(Collider other)
{
if (rig && rig.runner && rig.runner.IsResimulation)
{
// We only manage grabbing during forward ticks, to avoid detecting past positions of the grabbable object
return;
}
우선 OnTriggerStay
는 이미 객체가 그랩되었는지 확인합니다. 이 샘플에서는 간소화를 위해 다중 그랩을 허용하지 않습니다.
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;
}
그런 다음 다음을 확인합니다:
- 충돌한 객체가 그랩 가능한지 여부 (
Grabbable
컴포넌트가 있어야 함) - 사용자가 그립 버튼을 누르고 있는지 여부
이 조건이 충족되면 Grabbable Grab
메소드를 통해 그랩 한 객체가 손을 따르도록 요청됩니다.
C#
Grabbable grabbable;
if (lastCheckedCollider == other)
{
grabbable = lastCheckColliderGrabbable;
}
else
{
grabbable = other.GetComponentInParent<Grabbable>();
}
// To limit the number of GetComponent calls, we cache the latest checked collider grabbable result
lastCheckedCollider = other;
lastCheckColliderGrabbable = grabbable;
if (grabbable != null)
{
if (grabbable.currentGrabber != null)
{
// We don't allow multihand grabbing (it would have to be defined), nor hand swap (it would require to track hovering and do not allow grabbing while the hand is already close - or any other mecanism to avoid infinit swapping between the hands)
return;
}
if (hand.isGrabbing) Grab(grabbable);
}
Grabbable
의 Grab()
메소드는 그랩 한 위치의 오프셋을 저장합니다.
C#
public virtual void Grab(Grabber newGrabber)
{
// Find grabbable position/rotation in grabber referential
localPositionOffset = newGrabber.transform.InverseTransformPoint(transform.position);
localRotationOffset = Quaternion.Inverse(newGrabber.transform.rotation) * transform.rotation;
currentGrabber = newGrabber;
}
마찬가지로 객체가 더 이상 그랩 되지 않을 때 Grabbable
의 Ungrab()
호출은 객체에 대한 몇 가지 세부 정보를 저장합니다.
C#
public virtual void Ungrab()
{
currentGrabber = null;
if (networkGrabbable)
{
ungrabPosition = networkGrabbable.networkTransform.InterpolationTarget.transform.position;
ungrabRotation = networkGrabbable.networkTransform.InterpolationTarget.transform.rotation;
ungrabVelocity = Velocity;
ungrabAngularVelocity = AngularVelocity;
}
}
사용된 그랩 유형에 따라 일부 필드는 관련이 없을 수 있습니다(예: 물리적 그랩의 경우 언그랩 위치는 사용되지 않습니다).
그랩에 대한 모든 데이터(그랩 된 객체의 네트워크 ID, 오프셋, 해제 속도 및 위치 등)는 GrabInfo
구조체를 통해 입력 전송에서 공유됩니다.
C#
// Store the info describbing a grabbing state
public struct GrabInfo : INetworkStruct
{
public NetworkBehaviourId grabbedObjectId;
public Vector3 localPositionOffset;
public Quaternion localRotationOffset;
// We want the local user accurate ungrab position to be enforced on the network, and so shared in the input (to avoid the grabbable following "too long" the grabber)
public Vector3 ungrabPosition;
public Quaternion ungrabRotation;
public Vector3 ungrabVelocity;
public Vector3 ungrabAngularVelocity;
}
입력을 빌드 할 때, 그랩버는 최신 그랩 정보를 제공합니다.
C#
public void OnInput(NetworkRunner runner, NetworkInput input)
{
RigInput rigInput = new RigInput();
...
rigInput.leftGrabInfo = leftHand.grabber.GrabInfo;
rigInput.rightGrabInfo = rightHand.grabber.GrabInfo;
input.Set(rigInput);
}
public GrabInfo GrabInfo
{
get
{
if (resetGrabInfo)
return default;
if (grabbedObject)
{
_grabInfo.grabbedObjectId = grabbedObject.networkGrabbable.Id;
_grabInfo.localPositionOffset = grabbedObject.localPositionOffset;
_grabInfo.localRotationOffset = grabbedObject.localRotationOffset;
}
else
{
_grabInfo.grabbedObjectId = NetworkBehaviourId.None;
_grabInfo.ungrabPosition = ungrabPosition;
_grabInfo.ungrabRotation = ungrabRotation;
_grabInfo.ungrabVelocity = ungrabVelocity;
_grabInfo.ungrabAngularVelocity = ungrabAngularVelocity;
}
return _grabInfo;
}
}
호스트에서 이 정보가 수신되면 NetworkRig
에 저장되고, NetworkGrabber
의 GrabInfo
[Networked]
변수로 저장됩니다.
각 클라이언트에서 FixedUpdateNetwork()
동안 그랩 정보가 변경되었는지 확인합니다. 이 작업은 리시뮬레이션 중 그랩/언그랩을 재생하지 않기 위해 포워드 틱에서만 수행됩니다. 그랩 상태를 비교하기 위해 HandleGrabInfoChange
가 호출되며, 필요할 경우 NetworkGrabbable
에서 실제 Grab
및 Ungrab
메소드를 트리거 합니다.
C#
public override void FixedUpdateNetwork()
{
base.FixedUpdateNetwork();
if (Runner.IsForward)
{
// We only detect grabbing changes in forward, to avoid multiple Grab calls (that would have side effects in current implementation)
foreach (var changedPropertyName in changeDetector.DetectChanges(this))
{
if (changedPropertyName == nameof(GrabInfo))
{
// Grab info is filled by the NetworkRig, based on the input, and the input are filled with the Hardware rig Grabber GrabInfo
HandleGrabInfoChange(GrabInfo);
}
}
}
}
새로운 객체를 그랩 하기 위해, 메소드는 먼저 네트워크 ID를 사용하여 Object.Runner.TryFindBehaviour
를 통해 그랩 된 NetworkGrabbable
을 찾습니다.
C#
void HandleGrabInfoChange(GrabInfo newGrabInfo)
{
if (grabbedObject != null)
{
grabbedObject.Ungrab(this, newGrabInfo);
grabbedObject = null;
}
// We have to look for the grabbed object has it has changed
// If an object is grabbed, we look for it through the runner with its Id
if (newGrabInfo.grabbedObjectId != NetworkBehaviourId.None && Object.Runner.TryFindBehaviour(newGrabInfo.grabbedObjectId, out NetworkGrabbable newGrabbedObject))
{
grabbedObject = newGrabbedObject;
if (grabbedObject != null)
{
grabbedObject.Grab(this, newGrabInfo);
}
}
}
실제 네트워크 그랩, 언그랩 및 그랩버를 따라가는 방식은 선택된 그랩 유형에 따라 다릅니다.
키네마틱 그랩 유형
키네마틱 그랩 유형에서는 그랩 된 객체를 움직이기 위해 입력 권한을 변경할 필요가 없습니다.
객체의 위치 변경은 호스트(상태 권한)에 의해서만 수행되며, NetworkTransform
이 이를 모든 플레이어에게 위치 업데이트가 전달되도록 보장합니다.
추적
키네마틱 그랩 유형(KinematicGrabbable
및 NetworkKinematicGrabbable
클래스에서 구현됨)에서는 그랩 된 객체를 현재 손 위치로 텔레포트하는 방식으로 손을 추적합니다(상태 권한에 의해 수행됨).
C#
public void Follow(Transform followingtransform, Transform followedTransform)
{
followingtransform.position = followedTransform.TransformPoint(localPositionOffset);
followingtransform.rotation = followedTransform.rotation * localRotationOffset;
}
FixedUpdateNetwork
온라인 상태일 때, 다음 코드가 FixedUpdateNetwork()
호출 동안 실행됩니다.
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
grabbable.Follow(followingtransform: transform, followedTransform: currentGrabber.transform);
}
렌더
Render()
동안 수행되는 외삽과 관련해서는, NetworkKinematic
클래스는 [DefaultExecutionOrder(NetworkKinematicGrabbable.EXECUTION_ORDER)]
지시문을 사용하며, EXECUTION_ORDER = NetworkGrabber.EXECUTION_ORDER + 10
으로 설정되어 있어 필요에 따라 NetworkTransform
보간을 재정의합니다. 두 가지 경우를 처리해야 합니다.
- 객체가 그랩 된 동안의 외삽: 객체의 예상 위치는 손 위치에 있어야 합니다.
- 객체가 막 언그랩된 경우의 외삽: 객체가 그랩 된 동안 수행된 외삽과 네트워크 변환 보간이 동일하지 않으므로, 잠시 동안 외삽을 계속해야 합니다(즉, 객체가 언그랩 위치에 그대로 있어야 함). 그렇지 않으면 객체가 약간 뒤로 튀는 현상이 발생할 수 있습니다.
C#
public override void Render()
{
if (IsGrabbed)
{
// Extrapolation: Make visual representation follow grabber visual representation, 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
grabbable.Follow(followingtransform: transform, followedTransform: currentGrabber.transform);
}
else if (grabbable.ungrabTime != -1)
{
if ((Time.time - grabbable.ungrabTime) < ungrabResyncDuration)
{
// When the local user just ungrabbed the object, the network transform interpolation is still not the same as the extrapolation
// we were doing while the object was grabbed. So for a few frames, we need to ensure that the extrapolation continues
// (ie. the object stay still)
// until the network transform offers the same visual conclusion that the one we used to do
// Other ways to determine this extended extrapolation duration do exist (based on interpolation distance, number of ticks, ...)
transform.position = grabbable.ungrabPosition;
transform.rotation = grabbable.ungrabRotation;
}
else
{
// We'll let the NetworkTransform do its normal interpolation again
grabbable.ungrabTime = -1;
}
}
}
참고: 클라이언트가 객체를 그랩 하는 동안 첫 번째 틱과 [Networked] 변수가 설정되기 전 사이의 추가적인 외삽이 가능하며, 그 시점까지는 시각적인 손 위치가 약간 앞서갈 수 있습니다.
물리적 그랩 유형
키네마틱 그랩 유형과 달리, 물리적 그랩은 그랩 된 객체에 대한 입력 권한을 필요로 합니다.
물리적 그랩(이는 PhysicsGrabbable
및 NetworkPhysicsGrabbable
클래스에서 구현됨)은 각 틱 동안 그랩 된 객체가 손을 따라가도록 힘을 적용하거나 속도를 변경하는 방식으로 작동합니다.
그랩 가능 객체의 시뮬레이션
정확한 물리적 시뮬레이션을 적용하려면, 특히 그랩 된 객체가 로컬에서 그랩 된 객체와 충돌할 때(손에 끌리는 객체와 충돌하여 저항하는 경우), 그랩 된 객체는 모든 곳에서 시뮬레이션되어야 하며, 이를 통해 FixedUpdateNetwork
동안 각 플레이어에게 물리적 효과가 적용됩니다.
기본적으로, FixedUpdateNetwork
는 호스트와 입력 권한을 가진 곳에서만 호출됩니다. 프록시에서도 이를 실행하려면, 그랩 가능한 객체에 SetIsSimulated
가 적용되어야 합니다.
C#
Runner.SetIsSimulated(Object, true);
그랩 가능한 객체의 입력 권한이 변경될 때(다른 사용자가 객체를 그랩 했을 때), 이 설정은 기본값으로 재설정되므로 다시 설정해야 합니다.
C#
#region IInputAuthorityLost
public void InputAuthorityLost()
{
// When using Object.AssignInputAuthority, SetIsSimulated will be reset to false. as we want the object to remain simulated (see Spawned), we have to set it back
Runner.SetIsSimulated(Object, true);
}
#endregion
그랩버의 시뮬레이션
또한, 물리적 효과를 정확하게 적용하려면 그랩버(손)의 현재 위치가 FixedUpdateNetwork
호출 동안 적절히 설정되어야 하며, 여기에는 리시뮬레이션도 포함됩니다. 따라서 그랩버에도 SetIsSimulated
가 적용됩니다.
기본적으로는 예측 틱을 기반으로 프록시에서 손 보간이 수행되므로, 예측 정보가 없는 프록시에서는 손이 약간 끊어질 수 있습니다. 이를 방지하기 위해, 물리적 객체를 그랩 하는 동안에만 손을 시뮬레이션합니다(물리적 객체를 그랩 하는 동안 손은 인위적으로 객체 위치와 관련하여 표시되므로 시각적 문제가 발생하지 않습니다).
C#
bool isGrabbing = GrabInfo.grabbedObjectId != NetworkBehaviourId.None;
if (lastGrabbedObjectId != GrabInfo.grabbedObjectId)
{
lastGrabbedObject = null;
if (isGrabbing && Object.Runner.TryFindBehaviour(GrabInfo.grabbedObjectId, out NetworkGrabbable grabbedObject))
{
lastGrabbedObject = grabbedObject;
}
}
if (isSimulated == false && isGrabbing && lastGrabbedObject && lastGrabbedObject is NetworkPhysicsGrabbable)
{
// The hands need to be simulated to be at the appropriate position during FUN when a grabbable follow them (physics grabbable are fully simulated)
isSimulated = true;
Runner.SetIsSimulated(Object, isSimulated);
}
if (isSimulated == true && isGrabbing == false && Object.HasStateAuthority == false && Object.HasInputAuthority == false)
{
isSimulated = false;
Runner.SetIsSimulated(Object, isSimulated);
}
그랩 로직
NetworkGrabber
는 NetworkPhysicsGrabbable
에 대한 Grab
호출을 트리거 합니다. 이는 대부분 입력 권한 변경을 트리거 하며, 그랩 상태를 결정하는 입력 데이터는 그랩 하는 사용자의 입력에 기반합니다.
그 후 객체가 그랩되었는지 여부는 호스트와 입력 권한(그랩 한 플레이어)에 대한 입력에 따라 결정되며, 프록시의 경우 DetailedGrabInfo
네트워크 변수를 기반으로 결정됩니다(여기에는 그랩버와 그랩 세부 정보가 포함됨).
입력 권한 전환
입력 권한 전환으로 인해, 짧은 시간 동안 플레이어는 객체를 그랩 했지만 아직 입력 권한이 없는 상태일 수 있습니다.
이 과정은 일시적인 데이터를 저장하여 처리됩니다(예: willReceiveInputAuthority
불리언, 누가 "그랩버"가 될 것인지, 그랩 세부 정보 등). 이 데이터는 전환 기간 동안 그랩 가능한 객체의 기본 동작을 덮어씁니다.
C#
// Will be called by the host and by the grabbing user (the input authority of the NetworkGrabber) upon NetworkGrabber.GrabInfo change detection
// For other users, will be called by the local NetworkGrabbable.DetailedGrabInfo change detection
public override void Grab(NetworkGrabber newGrabber, GrabInfo newGrabInfo)
{
if (Object.InputAuthority != newGrabber.Object.InputAuthority)
{
if (newGrabber.Object.InputAuthority == Runner.LocalPlayer)
{
// Store data to handle the grab while the input authority transfer is pending
willReceiveInputAuthority = true;
inputAuthorityChangeTime = Time.time;
inputAuthorityChangeTick = Runner.Tick;
incomingGrabInfo = newGrabInfo;
incomingGrabber = newGrabber;
}
// Transfering the input authority of the cube is in fact not strickly required here (as the object is fully simulated on all clients)
if (Object.HasStateAuthority)
{
Object.AssignInputAuthority(newGrabber.Object.InputAuthority);
}
}
cachedGrabbers[(newGrabber.Object.InputAuthority, newGrabber.hand.side)] = newGrabber;
}
이 방식은 호스트가 아닐 때에도 로컬 플레이어에게 가장 빠른 반응성을 제공하도록 보장합니다.
렌더 시간 프레임
SetIsSimulated
을 사용해 프록시 시뮬레이션을 강제하면(즉, 물리 연산이 로컬에서 실행되도록), 객체는 항상 시뮬레이션됩니다. 따라서 기본적으로 렌더링 시 보간은 로컬에서 시뮬레이션된 틱 사이에서 이루어집니다.
하지만 물리 연산이 중요한 경우, 다른 시뮬레이션된 객체와의 충돌을 처리하기 위해 로컬에서 계산된 물리 상태를 사용해야 합니다. 그러나 원격 사용자의 손이 그 사이에 움직이지 않았기 때문에 객체의 위치가 완벽하게 예측되지 않습니다. 이로 인해 보간 중에 약간의 끊김이 발생할 수 있습니다(객체가 잠시 동안 움직이지 않다가 갑자기 이동하는 현상).
따라서 FixedUpdateNetwork()
에서 계산된 위치는 로컬 물리 계산에 사용되지만, 해당 객체의 최종 렌더링에는 원격 시간 프레임을 사용하는 것이 좋습니다. 이는 손의 위치가 제대로 설정된 상태에서 객체가 적절하게 이동할 수 있도록 합니다.
이를 위해 FixedUpdateNetwork
동안 서버에서 확인된 틱을 저장합니다.
C#
// Store the first resim ticks (latest confirmed from the host) for this simulation time, in order to compute a remote timeframe in render when grabbed by a remote hand
lastLocalizations.Add(new Localization { time = Runner.SimulationTime, pos = transform.position, rot = transform.rotation });
while (lastLocalizations.Count > 20)
{
lastLocalizations.RemoveAt(0);
}
그리고 Render
에서 원격 사용자가 객체를 그랩 했을 때, NetworkRigidbody
의 보간(로컬 렌더 시간 프레임에 있는)을 원격 시간 프레임 보간으로 대체합니다.
C#
if (CurrentGrabber != null && CurrentGrabber.HasInputAuthority == false && willReceiveInputAuthority == false)
{
Localization from = default, to = default;
bool fromFound = false;
bool toFound = false;
float targetTime = Runner.RemoteRenderTime;
foreach(var loc in lastLocalizations)
{
if(loc.time < targetTime)
{
fromFound = true;
from = loc;
}
else
{
to = loc;
toFound = true;
break;
}
}
if(fromFound && toFound)
{
float remoteAlpha = (float)Maths.Clamp01((targetTime - from.time) / (to.time - from.time));
networkRigidbody.InterpolationTarget.transform.position = Vector3.Lerp(from.pos, to.pos, remoteAlpha);
networkRigidbody.InterpolationTarget.transform.rotation = Quaternion.Slerp(from.rot, to.rot, remoteAlpha);
}
}
언그랩 릴리스 속도
가상 현실에서 손의 움직임은 매우 빠르고 정확합니다.
사용자가 객체를 놓을 때 대부분의 경우 틱 사이에서 발생하며, 정확히 틱에서 발생하지는 않습니다.
따라서 마지막으로 기록된 틱과 객체가 실제로 놓인 서브 틱 순간 사이에 속도의 방향에 상당한 차이가 있을 수 있습니다.
사용자가 기대하는 대로 객체가 놓이는 동작을 제공하기 위해, 놓는 속도는 입력에 저장되고, 그런 다음 프록시의 DetailedGrabInfo
네트워크 변수에 저장됩니다. 이를 통해 객체를 놓을 때 예상되는 물리적 효과가 모든 클라이언트에서 정확하게 재생되도록 보장합니다.
FixedUpdateNetwork
SetIsSimulated
설정으로 인해 FixedUpdateNetwork
는 모든 클라이언트에서 항상 호출됩니다(프록시 포함).
NetworkPhysicsGrabbable
에서는 다음과 같은 작업을 주로 처리합니다:
- 손이 객체를 그랩 했는지 결정 (호스트와 입력 권한 보유자를 위한 입력을 통해, 프록시의 경우
DetailedGrabInfo
를 통해) - 그랩 된 상태에서 손을 따라가도록 물리적 효과를 객체에 적용
DetailedGrabInfo
를 저장하여 프록시가 객체가 그랩 되었음을 알고 물리적 효과를 적용할 수 있도록 함- 객체가 놓인 틱에서 릴리스 속도를 적용 (앞으로 또는 리시뮬레이션 틱)
- 입력 권한 전환의 특별한 경우 처리 (객체가 이미 로컬에서 그랩 되었지만, 그랩 한 사용자가 아직 입력 권한을 갖고 있지 않은 경우)
- 그랩/언그랩 이벤트 트리거 (리시뮬레이션으로 인해 여러 번 이벤트가 트리거되는 것을 방지하기 위해, 포워드 단계에서만 새로 고침되는 변경 감지기를 사용)
C#
public override void FixedUpdateNetwork()
{
// ---- Handle waiting for input authority reception
if (willReceiveInputAuthority && Object.HasInputAuthority)
{
// Authority received
willReceiveInputAuthority = false;
}
if (willReceiveInputAuthority && (Time.time - inputAuthorityChangeRequestTime) > 1)
{
// Authority not received (quickly grabbed by someone else ?)
willReceiveInputAuthority = false;
}
// ---- Reference previous state (up to date for host / input authority only - proxies grab info will always remain at the last confirmed value)
bool wasGrabbed = DetailedGrabInfo.grabbingUser != PlayerRef.None;
var previousGrabberId = DetailedGrabInfo.grabberId;
// ---- Determine grabber/grab info for this tick
bool isGrabbed = false;
GrabInfo grabInfo = default;
NetworkGrabber grabber = null;
bool grabbingWhileNotYetInputAuthority = willReceiveInputAuthority && Runner.Tick > inputAuthorityChangeRequestTick;
if (grabbingWhileNotYetInputAuthority)
{
// We are taking the input authority: we anticipate the grab before being able to read GetInput, by setting "manually" the grabber
grabInfo = incomingGrabInfo;
grabber = incomingGrabber;
}
else if (GetInput<RigInput>(out var input))
{
// Host or input authority: we use the input to replay the exact moment of the grab/ungrab in resims
isGrabbed = false;
if (input.leftGrabInfo.grabbedObjectId == Id)
{
isGrabbed = true;
grabInfo = input.leftGrabInfo;
PlayerRef grabbingUser = Object.InputAuthority;
grabber = GrabberForSideAndPlayer(grabbingUser, RigPart.LeftController);
previousGrabbingSide = RigPart.LeftController;
}
else if (input.rightGrabInfo.grabbedObjectId == Id)
{
isGrabbed = true;
// one-hand grabbing only in this implementation
grabInfo = input.rightGrabInfo;
PlayerRef grabbingUser = Object.InputAuthority;
grabber = GrabberForSideAndPlayer(grabbingUser, RigPart.RightController);
previousGrabbingSide = RigPart.RightController;
}
else if (wasGrabbed && previousGrabbingSide != RigPart.None)
{
grabInfo = previousGrabbingSide == RigPart.LeftController ? input.leftGrabInfo : input.rightGrabInfo;
}
}
else
{
// Proxy
isGrabbed = DetailedGrabInfo.grabbingUser != PlayerRef.None;
// one-hand grabbing only in this implementation
grabInfo = DetailedGrabInfo.grabInfo;
if (isGrabbed) grabber = GrabberForId(DetailedGrabInfo.grabberId);
}
// ---- Apply following move based on grabber/grabinfo
if (isGrabbed)
{
grabbable.localPositionOffset = grabInfo.localPositionOffset;
grabbable.localRotationOffset = grabInfo.localRotationOffset;
Follow(followedTransform: grabber.transform, elapsedTime: Runner.DeltaTime, isColliding: IsColliding);
}
// ---- Store DetailedGrabInfo changes
if (isGrabbed && (wasGrabbed == false || previousGrabberId != grabber.Id))
{
// We do not store data as proxies, unless if we are waiting for the input authority
if (Object.IsProxy == false || grabbingWhileNotYetInputAuthority)
{
DetailedGrabInfo = new DetailedGrabInfo
{
grabbingUser = grabber.Object.InputAuthority,
grabberId = grabber.Id,
grabInfo = grabInfo,
};
}
}
if (wasGrabbed && isGrabbed == false)
{
// We do not store data as proxies, unless if we are waiting for the input authority
if (Object.IsProxy == false || grabbingWhileNotYetInputAuthority)
{
DetailedGrabInfo = new DetailedGrabInfo
{
grabbingUser = PlayerRef.None,
grabberId = previousGrabberId,
grabInfo = grabInfo,
};
}
// Apply release velocity (the release timing is probably between tick, so we stored in the input the ungrab velocity to have sub-tick accuracy)
grabbable.rb.velocity = grabInfo.ungrabVelocity;
grabbable.rb.angularVelocity = grabInfo.ungrabAngularVelocity;
}
// ---- Trigger callbacks and release velocity
// Callbacks are triggered only during forward tick to avoid triggering them several time due to resims.
// If we are waiting for input authority, we do not check (and potentially trigger) the callbacks, as the DetailedGrabInfo will temporarily be erased by the server, and so that might trigger twice the callbacks later
if (Runner.IsForward && grabbingWhileNotYetInputAuthority == false)
{
TriggerCallbacksOnForwardGrabbingChanges();
}
// ---- Consume the isColliding value: it will be reset in the next physics simulation (used in PID based moves)
IsColliding = false;
// ...
}
따라가기
물리 그랩 유형의 경우, 현재 그랩 한 사용자를 따라가는 것은 그랩 된 객체의 속도를 변경하여 결국 그랩 한 사용자에게 다시 합류하도록 만드는 것을 의미합니다.
이는 속도를 직접 변경하거나, 원하는 물리적 특성에 따라 힘을 사용하여 수행될 수 있습니다. 샘플에서는 PID(비례 적분 미분) 및 직접 속도 모드를 제공합니다. 기본 모드는 Velocity
이며, 이는 각 객체의 PhysicsGrabbable
컴포넌트에서 변경할 수 있습니다.
C#
public virtual void VelocityFollow(Transform followedTransform, float elapsedTime)
{
// Compute the requested velocity to joined target position during a Runner.DeltaTime
rb.VelocityFollow(target: followedTransform, localPositionOffset, localRotationOffset, elapsedTime);
// To avoid a too aggressive move, we attenuate and limit a bit the expected velocity
rb.velocity *= followVelocityAttenuation; // followVelocityAttenuation = 0.5F by default
rb.velocity = Vector3.ClampMagnitude(rb.velocity, maxVelocity); // maxVelocity = 10f by default
}
C#
public static void VelocityFollow(this Rigidbody followerRb, Transform target, Vector3 positionOffset, Quaternion rotationOffset, float elapsedTime)
{
followerRb.VelocityFollow(target.TransformPoint(positionOffset), target.rotation * rotationOffset, elapsedTime);
}
public static void VelocityFollow(this Rigidbody followerRb, Vector3 targetPosition, Quaternion targetRotation, float elapsedTime)
{
Vector3 positionStep = targetPosition - followerRb.transform.position;
Vector3 velocity = positionStep / elapsedTime;
followerRb.velocity = velocity;
followerRb.angularVelocity = followerRb.transform.rotation.AngularVelocityChange(newRotation: targetRotation, elapsedTime: elapsedTime);
}
렌더
물리 계산으로 인한 위치 간섭을 방지하기 위해, 키네마틱 그랩과 달리, 여기서 Render()
로직은 그랩 된 객체의 시각적 위치를 손의 시각적 위치로 강제 이동하지 않습니다.
여러 가지 선택지가 있으며, 아무것도 하지 않는 경우도 있습니다(이를 통해 손이 객체를 통과할 때 충돌과 관련된 유효한 결과를 얻을 수 있습니다).
현재 샘플에서 사용되는 Render
로직은 다음과 같습니다:
- 그랩 된 객체의 시각적 위치가 손의 시각적 위치에 고정되는 대신, 손의 시각적 위치가 그랩 된 객체의 시각적 위치를 따르게 강제됩니다.
- 충돌이 발생할 경우, 실제 손의 위치와 표시된 손의 위치 간에 차이가 생길 수 있습니다. 이를 편안하게 하기 위해 "유령 손"이 실제 손의 위치에 표시됩니다.
- 사용자가 이러한 불일치(특히 충돌 중에)를 느낄 수 있도록, 컨트롤러는 표시된 손과 실제 손의 거리 차이에 비례하여 진동을 보냅니다. 이는 약간의 저항감을 제공합니다.
- 객체를 놓을 때 손의 위치를 부드럽게 복원하는 데는 신경 쓰지 않지만, 필요하다면 추가할 수 있습니다.
C#
public override void Render()
{
base.Render();
if (Object.InputAuthority != Runner.LocalPlayer)
{
// Allow to prevent local hardware grabbing of the same object
grabbable.isGrabbed = IsGrabbed;
}
// ...
// We don't place the hand on the object while we are waiting to receive the input authority as the timeframe transitioning might lead to erroneous hand repositioning
if (IsGrabbed && willReceiveInputAuthority == false)
{
var handVisual = CurrentGrabber.hand.transform;
var grabbableVisual = networkRigidbody.InterpolationTarget.transform;
// On remote user, we want the hand to stay glued to the object, even though the hand and the grabbed object may have various interpolation
handVisual.rotation = grabbableVisual.rotation * Quaternion.Inverse(grabbable.localRotationOffset);
handVisual.position = grabbableVisual.position - (handVisual.TransformPoint(grabbable.localPositionOffset) - handVisual.position);
// Add pseudo haptic feedback if needed
ApplyPseudoHapticFeedback();
}
}
타사
다음
다음은 이 프로젝트에서 수정하거나 개선할 수 있는 몇 가지 제안 사항입니다:
- 로컬 텔레포트 레이를 다른 플레이어에게 표시 (텔레포트 레이 데이터를
OnInput
호출 중 공유되는RigInput
구조에 추가) - 음성 기능 추가. Fusion에서 Photon Voice 통합에 대한 자세한 내용은 이 페이지를 참조하십시오: Photon Voice Integration
- 실행 중에 추가 네트워크 객체를 생성하는 버튼 만들기