VR ホスト
概要
Fusion VR Host は、VRを使ったマルチプレイヤーゲームやアプリケーションを素早く簡単に開始するための方法を紹介します。
共有またはホスト/サーバーのトポロジーを選択するには、ゲームの特殊性によって駆動する必要があります。このサンプルでは、ホストモード を使用しています。
このサンプルの目的は、VRリグの処理方法を明確にし、基本的なテレポートとグラブの例を提供することです。
はじめに
- このプロジェクトは、Unity 2021.3.7f1 と Fusion 1.1.3 で開発されています。
- サンプルを実行するには、まず、PhotonEngine Dashboard で Fusion AppId を作成し、リアルタイム設定 (Fusion メニュー) の
App Id Fusion
欄にペーストしてください。次に、Launch
シーンを読み込んで、Play
を押してください。
ダウンロード
バージョン | リリース日 | ダウンロード | |
---|---|---|---|
1.1.8 | Sep 21, 2023 | Fusion VR Host 1.1.8 Build 278 |
入力処理
メタクエスト
- テレポート : 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 = mode,
SessionName = roomName,
Scene = SceneManager.GetActiveScene().buildIndex,
SceneManager = sceneManager
};
await runner.StartGame(args);
}
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)
{
Debug.Log($"OnPlayerJoined {player.PlayerId}/Local id: ({runner.LocalPlayer.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);
}
}
// Despawn the user object upon disconnection
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);
}
}
UnityのゲームオブジェクトConnection Manager
で、「Auto Host or Client」が選択されていることを確認してください。
リグ
概要
没入型アプリケーションでは、リグはユーザーを表現するために必要なすべての可動部、通常は両手、頭、プレイエリア(例えばユーザーがテレポートしたときに移動できるパーソナルスペースです)を記述します。
ネットワークセッション中、すべてのユーザーはネットワーク化されたリグによって表現され、その様々なパーツの位置はネットワーク上で同期されます。
リグパーツがどのように編成され、同期されるかについては、いくつかのアーキテクチャが可能であり、有効です。ここでは、ユーザーを一つの NetworkObject
で表現し、リグパーツごとにいくつかの NetworkTransforms
をネストしています。
ローカルユーザーを表すネットワークリグの場合、このリグはハードウェア入力によって駆動される必要があります。このプロセスを単純化するために、ネットワークに接続されていない、別のリグを作成しました。ハードウェアの入力を収集するために、クラシックなUnityコンポーネントを使用します(TrackedPoseDriver
など)。
詳細
リグ
リグを動かす全てのパラメータ(空間での位置や手のポーズ)は 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)
{
// Prepare the input, that will be read by NetworkRig in the FixedUpdateNetwork
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
(これは状態や 入力権限に対する入力のみを返します) を通して行われます。
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;
}
}
NetworkRig
コンポーネントは、FixedUpdateNetwork()
の間にネットワークリグパーツの位置を移動する以外に、ローカルの外挿も処理します。Render()
中に、このオブジェクトに対する権限を持つローカルユーザに対して、様々なリグパーツの NetworkTransforms
のグラフィック表示を処理する内挿ターゲットが、最新のローカルハードウェア リグパーツデータを用いて移動されます。
画面のリフレッシュレートがネットワークのティックレートよりも高い場合でも、ローカルユーザーが自分の手の位置を常に最新に保つことを保証します(潜在的な不安を避けるため)。
クラスの前の [OrderAfter]
タグは、NetworkRig
の Render()
が 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
// To update the visual object, and not the actual networked position, we move the interpolation targets
networkTransform.InterpolationTarget.position = hardwareRig.transform.position;
networkTransform.InterpolationTarget.rotation = hardwareRig.transform.rotation;
leftHand.networkTransform.InterpolationTarget.position = hardwareRig.leftHand.transform.position;
leftHand.networkTransform.InterpolationTarget.rotation = hardwareRig.leftHand.transform.rotation;
rightHand.networkTransform.InterpolationTarget.position = hardwareRig.rightHand.transform.position;
rightHand.networkTransform.InterpolationTarget.rotation = hardwareRig.rightHand.transform.rotation;
headset.networkTransform.InterpolationTarget.position = hardwareRig.headset.transform.position;
headset.networkTransform.InterpolationTarget.rotation = hardwareRig.headset.transform.rotation;
}
ヘッドセット
The NetworkHeadset
class is very simple : it provides an access to the headset NetworkTransform
for the NetworkRig
class
C#
public class NetworkHeadset : NetworkBehaviour
{
[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)
function) によって指の位置を変更するために、このインターフェースを実装しています。
では、どのように同期しているのかを見てみましょう。
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;
}
各 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
というネットワーク構造体を含んでいます。
C#
[Networked(OnChanged = nameof(OnHandCommandChange))]
public HandCommand HandCommand { get; set; }
HandCommand
はネットワーク上の変数なので、ネットワーク上の構造が変わるたびに、すべてのクライアントで OnHandCommandChange()
がコールバックされ、それに応じて手の表現が更新されます。
C#
public static void OnHandCommandChange(Changed<NetworkHand> changed)
{
// Will be called on all clients when the local user change the hand pose structure
// We trigger here the actual animation update
changed.Behaviour.UpdateHandRepresentationWithNetworkState();
}
C#
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();
}
}
C#
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);
}
掴む
概要
この掴み方のロジックは、2つのパートに分かれています。
- ハードウェアハンドが掴めるオブジェクトに対して掴みアクションを起こしたときに、実際の掴みと掴み解除を検出するローカルな部分 (
Grabber
とGrabbable
クラス) - ネットワーク化された部分:すべてのプレイヤーが掴んだ状態を認識できるようにし、掴んだ手を追うために実際の位置変更を管理する (
NetworkGrabber
とNetworkGrabbable
クラス).
*注:このコードには、オフラインで使用する際にローカルパートが追従を管理するための行がいくつか含まれています。例えば、同じコンポーネントがオフラインのロビーに使用されるような使用例です。しかし、このドキュメントでは、実際にネットワークで使用することに焦点を当てます。
このサンプルでは、2種類の掴み方が用意されています (Grabbable
と NetworkGrabbable
クラスは抽象クラスで、サブクラスでそれぞれのロジックを実装しています)。
- キネマティックオブジェクトの掴み:その位置は単純に掴んだ手の位置に従います。他のオブジェクトと物理的なインタラクションを持つことはできません。
KinematicGrabbable
とNetworkKinematicGrabbable
クラスで実装されています。 - 物理オブジェクトの掴み:掴んだ手に追従するように速度が変更されます。他のオブジェクトと物理的なインタラクションを持つことができ、発射することもできます。この実装では、サーバーの物理モードがクライアント側の予測に設定されていることが必要です。
PhysicsGrabbable
とNetworkPhysicsGrabbable
クラスで実装されています。
*注:運動オブジェクトにリリース速度を与えることが可能ですが、追加のコードが必要になるため、このサンプルでは追加していません(この別種のグラブは、単純なグラブのユースケースのための非常にシンプルなコードベースを示すためにここにあります)
詳細
各手にある HardwareHand
クラスは、isGrabbing
bool を更新するたびに更新します。ユーザーがグリップ ボタンを押すと、bool は true になります。
updateGrabWithAction
ブールは、マウスとキーボードで操作できるリグである deskop rig をサポートするために使用されます (このブールは、デスクトップモードでは 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;
}
掴むことが可能なオブジェクトとの衝突を検出するために、単純なボックスコライダーが各ハードウェアハンドに配置され、このハンド上に配置された Grabber
コンポーネントによって使用されます。衝突が発生すると、メソッド OnTriggerStay()
が呼び出されます。
ホストトポロジーでは、いくつかのティックはフォワードティック(実際の新しいティック)であり、他のティックはリシミュレーション(過去のインスタントのリプレイ)であることに注意してください。掴んだり離したりするのは、現在の位置に対応するforward tickの間だけであるべきです。そのため、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 (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 GrabInfo GrabInfo
{
get
{
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]
var に格納します。
そこで、各クライアントで、FixedUpdateNetwork()
の間に、クラスは掴み情報が変更されたかどうかを確認します。これは、再シミュレーション中に掴み/放すが再生されるのを避けるために、前方ティックでのみ行われます。これは HandleGrabInfoChange
を呼び出すことで行われ、手の前回と今回の掴み状態を比較します。必要であれば、NetworkGrabbable
の実際の Grab
と Ungrab
メソッドをトリガーします。
新しいオブジェクトを掴むには、まずこのメソッドはネットワーク ID で検索して NetworkGrabbable
を見つけ、Object.Runner.TryFindBehaviour
で検索します。
C#
void HandleGrabInfoChange(GrabInfo previousGrabInfo, GrabInfo newGrabInfo)
{
if (previousGrabInfo.grabbedObjectId != newGrabInfo.grabbedObjectId)
{
if (grabbedObject != null)
{
grabbedObject.Ungrab(newGrabInfo);
grabbedObject = null;
}
// We have to look for the grabbed object has it has changed
NetworkGrabbable newGrabbedObject;
// 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 newGrabbedObject))
{
grabbedObject = newGrabbedObject;
if (grabbedObject != null)
{
grabbedObject.Grab(this, newGrabInfo);
}
}
}
}
実際の掴み、解き、掴むプレイヤーの追従は、選択された掴みタイプによって異なります。
キネマティック掴みタイプ
追従
キネマティック掴みタイプの場合、現在の掴むプレイヤーに従うことは、単にその実際の位置へテレポートすることです。
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);
}
位置の変更はホスト (状態の権限) でのみ行われ、その後 NetworkTransform
によってすべてのプレーヤーが位置の更新を受け取るようにします。
レンダリング
Render()
(必要に応じて NetworkTransform
の補間をオーバーライドする OrderAfter
ディレクティブとして NetworkKinematic
クラスがあります) の間に行われる外挿について、ここでは2つのケースを処理する必要があります。
- オブジェクトが掴まれた状態での外挿:オブジェクトの予想位置は分かっており、手の位置と同じであるべきです。そのため、掴めるビジュアル(つまり
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: networkTransform.InterpolationTarget.transform, followedTransform: currentGrabber.networkTransform.InterpolationTarget.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, ...)
networkTransform.InterpolationTarget.transform.position = grabbable.ungrabPosition;
networkTransform.InterpolationTarget.transform.rotation = grabbable.ungrabRotation;
}
else
{
// We'll let the NetworkTransform do its normal interpolation again
grabbable.ungrabTime = -1;
}
}
}
注意: 例えば、クライアントがオブジェクトを掴むとき、実際に掴んでから [Networked] 変数が設定された最初のティックの間に、追加のエッジケースのために、いくつかの追加の外挿が行われるかもしれません。手の視覚は、そうなる前に少し (数ミリ秒) 追跡できます。
物理的な掴み方のタイプ
追従
キネマティックな掴み方の場合、現在の掴むプレイヤーに従うということは、掴まれたオブジェクトの速度を変え、最終的に掴むプレイヤーに戻るということを意味します。
これは、直接速度を変えるか、力を使って行うかなど、どのような物理を求めるかによって決まります。
サンプルは両方のロジックを示しており、PhysicsGrabbable
でそれを選択するオプションがあります。直接速度を変化させる方が簡単な方法です。
C#
void Follow(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
}
FixedUpdateNetwork
物理計算を適切に行うためには、後述の FixedUpdateNetwork
の説明で述べるように、ネットワーク入力データが掴む側のクライアントに必要です。そのため、Grab()
の際に入力権限を適切に割り当てる必要があります。
C#
public override void Grab(NetworkGrabber newGrabber, GrabInfo newGrabInfo)
{
grabbable.localPositionOffset = newGrabInfo.localPositionOffset;
grabbable.localRotationOffset = newGrabInfo.localRotationOffset;
currentGrabber = newGrabber;
if (currentGrabber != null)
{
lastGrabbingUser = currentGrabber.Object.InputAuthority;
}
lastGrabber = currentGrabber;
DidGrab();
// We store the precise grabbing tick to be able to determined if we are grabbing during resimulation tick,
// where tha actual currentGrabber may have changed in the latest forward ticks
startGrabbingTick = Runner.Tick;
endGrabbingTick = -1;
}
FixedUpdateNetwork
の間、各クライアントは以下のコードを実行し、掴んだオブジェクトの速度が掴んだ手のところまで移動するようにします。
注意すべき重要な点は、FixedUpdateNetwork
はクライアントで呼ばれることです。
- フォワードティック(初めて計算されるティックで、信頼できる最新のホストデータの後に何が起こるかを予測しようとするもの)の間。
- resim ティック (新しいデータがサーバから到着したときに再計算される予測ティック。以前の予測ティックと矛盾する可能性がある)
ユーザーがオブジェクトを掴んでいる間は、Follow
コードは各ティックに独立して関連しているので、あまり重要ではありません。
しかし、ユーザーが掴んでいたオブジェクトを放すと、resim ティックの間に、オブジェクトがまだ掴まれている間に発生したティックもあれば、まだ掴まれていない間に発生したティックもあります。しかし、Ungrab()
の呼び出しで currentGrabber
変数が null に設定されたので、ungrab の前の resim ティックにはもう適しません。
そこで、1ティックの間に実際の掴み状態を確実に知るために、掴みと掴み解除に関連するティックを startGrabbingTick
と endGrabbingTick
という変数に格納します。そして、FixedUpdateNetwork()
の中で、再シミュレーションの間、これらの変数が、オブジェクトがこのティックの間に実際につかまれたかどうかを判断するために使用されます。
C#
public override void FixedUpdateNetwork()
{
if (Runner.IsForward)
{
// during forward tick, the IsGrabbed is reliable as it is changed during forward ticks
// (more precisely, it is one tick late, due to OnChanged being called AFTER FixedUpdateNetwork,but this way every client, including proxies, can apply the same physics)
if (IsGrabbed)
{
grabbable.Follow(followedTransform: currentGrabber.transform, elapsedTime: Runner.DeltaTime);
}
}
if (Runner.IsResimulation)
{
bool isGrabbedDuringTick = false;
if (startGrabbingTick != -1 && Runner.Tick >= startGrabbingTick)
{
if (Runner.Tick < endGrabbingTick || endGrabbingTick == -1)
{
isGrabbedDuringTick = true;
}
}
if (isGrabbedDuringTick)
{
grabbable.Follow(followedTransform: lastGrabber.transform, elapsedTime: Runner.DeltaTime);
}
// For resim, we reapply the release velocity on the Ungrab tick, like it was done in the Forward tick where it occurred first.
if (endGrabbingTick == Runner.Tick)
{
grabbable.rb.velocity = lastUngrabVelocity;
grabbable.rb.angularVelocity = lastUngrabAngularVelocity;
}
}
}
レンダリング
物理計算によって補間された位置の混乱を避けるため、ここでのRender()ロジックは、掴まれたオブジェクトの視覚的位置を手の視覚的位置に強制しないようにしています。
いくつかのオプションが利用可能です(何もしないこともできますが、その場合、例えば衝突したときに手が掴んだオブジェクトを通過してしまうなど、関連する選択肢が生じる可能性があります)。
現在のサンプルの実装では、以下の Render
ロジックを使用しています。
- 掴んだオブジェクトのビジュアルが手のビジュアルの上にあるのではなく、手のビジュアル位置が掴んだオブジェクトのビジュアル位置に従うように強制されます。
- このため、衝突した場合、実際の手の位置と表示される手の位置が異なることがあります。そこで、現実の手の位置に "ゴースト "の手を表示することで、違和感を感じさせないようにしました。
- また、衝突時の衝撃を緩和するために、表示されている手の位置と実際の手の位置のズレに比例した振動をコントローラーに与え、わずかな抵抗感を与えています。
- オブジェクトを放すときに、手の位置をスムーズに復元するための努力はしていません(ただし、必要に応じて追加することができます)。
C#
public override void Render()
{
base.Render();
if (IsGrabbed)
{
var handVisual = currentGrabber.hand.networkTransform.InterpolationTarget.transform;
var grabbableVisual = networkTransform.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();
}
}
// Display a ghost" hand at the position of the real life hand when the distance between the representation (glued to the grabbed object, and driven by forces) and the IRL hand becomes too great
// Also apply a vibration proportionnal to this distance, so that the user can feel the dissonance between what they ask and what they can do
void ApplyPseudoHapticFeedback()
{
if (pseudoHapticFeedbackConfiguration.enablePseudoHapticFeedback && IsGrabbed && IsLocalPlayerMostRecentGrabber)
{
if (currentGrabber.hand.LocalHardwareHand.localHandRepresentation != null)
{
var handVisual = currentGrabber.hand.networkTransform.InterpolationTarget.transform;
Vector3 dissonanceVector = handVisual.position - currentGrabber.hand.LocalHardwareHand.transform.position;
float dissonance = dissonanceVector.magnitude;
bool isPseudoHapticNeeded = (isColliding && dissonance > pseudoHapticFeedbackConfiguration.minContactingDissonance);
currentGrabber.hand.LocalHardwareHand.localHandRepresentation.DisplayMesh(isPseudoHapticNeeded);
if (isPseudoHapticNeeded)
{
currentGrabber.hand.LocalHardwareHand.SendHapticImpulse(amplitude: Mathf.Clamp01(dissonance / pseudoHapticFeedbackConfiguration.maxDissonanceDistance), duration: pseudoHapticFeedbackConfiguration.vibrationDuration);
}
}
}
}
サードパーティー
次に
このプロジェクトで実践できる、いくつかの修正・改善案を紹介します。
- ローカルテレポートレイを他のプレイヤーに表示する(ネットワーク変数または
INetworkInput
を使用する) - 音声機能を追加する。Photon VoiceとFusionの連携については、こちらのページを参照してください。https://doc.photonengine.com/en-us/voice/current/getting-started/voice-for-fusion
- 実行時に追加のネットワークオブジェクトを生成するためのボタンを作成。
Changelog
- Fusion VR Host 1.1.2 Build 5
- fix to grab/ungrab detection to limit them to Forward ticks
- fix to a bug when the user tries to swap the grabbing hands
- added the optionnal PseudoHapticGrabbableRender to display a ghost grabbed object during pseudo-haptic feedback (ie. when ghost IRL hands appear) for physics grabbable objects
- in NetworkGrabber, use FixedUpdateNetwork instead of OnChanged to have the grabbing info 1 tick earlier
- remove secondary following type, and set
Follow()
as virtual in PhysicsGrabbable to allow developpers to implement their own following physics - allow proxy user grabbing physics: the input authority is not changed anymore, neither the InterpolationDataSource, but instead the grabbing/ungrabbing ticks are memorized to determine on each clients the grabbing status during resimulations