593 lines
22 KiB
C#
593 lines
22 KiB
C#
using System.Collections.Generic;
|
|
using Unity.Netcode;
|
|
using Unity.XR.CoreUtils;
|
|
using UnityEngine;
|
|
using UnityEngine.InputSystem;
|
|
using UnityEngine.XR.Hands;
|
|
using UnityEngine.XR.Interaction.Toolkit.Inputs;
|
|
|
|
namespace XRMultiplayer
|
|
{
|
|
/// <summary>
|
|
/// This class will synchronize the hand poses over the network.
|
|
/// It will also allow for the user to control the fidelity of the hand poses and how much data is being sent over the network.
|
|
/// This class is going to have major changes in the near future based on the Hand Pose work being done.
|
|
/// </summary>
|
|
public class XRHandPoseReplicator : NetworkBehaviour
|
|
{
|
|
/// <summary>
|
|
/// Controls the level of fidelity and how much data is being sent over the network.
|
|
/// 0 is highest level of fidelity and the most bandwidth, 2 is the lowest and the least bandwidth.
|
|
/// </summary>
|
|
[Header("Hands and Fingers"), Tooltip("0 is highest, 2 is lowest")]
|
|
[Range(0, 2), SerializeField] int m_FidelityLevel;
|
|
[SerializeField, Tooltip("Determines minimum value threshold for updating finger rotations")] float m_MinUpdateDelta = .1f;
|
|
[SerializeField] JointBasedHand[] m_HandCurler;
|
|
|
|
[SerializeField] float m_FingerLerpSpeed = 20.0f;
|
|
[SerializeField] bool m_UpdateHandsLocally;
|
|
|
|
[Header("Controller Inputs")]
|
|
[SerializeField] InputActionProperty[] m_GripInputProperties;
|
|
[SerializeField] InputActionProperty[] m_TriggerInputProperties;
|
|
[SerializeField] InputActionProperty[] m_ThumbTouchProperties;
|
|
|
|
NetworkList<Vector3> m_FingerRotationsLeft;
|
|
NetworkList<Vector3> m_FingerRotationsRight;
|
|
|
|
NetworkList<float> m_FingerCurlLeft;
|
|
NetworkList<float> m_FingerCurlRight;
|
|
|
|
NetworkVariable<bool> m_IsInitialized = new NetworkVariable<bool>(false, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
|
|
|
Pose[] m_handTrackedStartPose = new Pose[2];
|
|
|
|
[Header("Offsets")]
|
|
[SerializeField] Vector3[] m_HandControllerOffsets;
|
|
[SerializeField] Vector3[] m_HandControllerEulerOffsets;
|
|
HandFidelityOption[] m_LocalHandFidelityOptions;
|
|
|
|
public XRInputModalityManager.InputMode trackingType { get => m_TrackingType.Value; }
|
|
readonly NetworkVariable<XRInputModalityManager.InputMode> m_TrackingType = new(XRInputModalityManager.InputMode.None, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
|
|
|
XRInputModalityManager m_XRModalityManager;
|
|
XROrigin m_XROrigin;
|
|
|
|
Transform m_LeftHandTransformReference;
|
|
Transform m_RightHandTransformReference;
|
|
|
|
Transform m_LeftControllerTransformReference;
|
|
Transform m_RightControllerTransformReference;
|
|
|
|
/// <summary>
|
|
/// Internal references to the Local Player Transforms.
|
|
/// </summary>
|
|
protected Transform m_LeftHandOrigin, m_RightHandOrigin;
|
|
|
|
private void Awake()
|
|
{
|
|
m_FingerRotationsLeft = new NetworkList<Vector3>(new List<Vector3>(), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
|
m_FingerRotationsRight = new NetworkList<Vector3>(new List<Vector3>(), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
|
|
|
m_FingerCurlLeft = new NetworkList<float>(new List<float>(), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
|
m_FingerCurlRight = new NetworkList<float>(new List<float>(), NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Owner);
|
|
|
|
for (int i = 0; i < m_HandCurler.Length; i++)
|
|
{
|
|
m_handTrackedStartPose[i].position = m_HandCurler[i].transform.localPosition;
|
|
m_handTrackedStartPose[i].rotation = m_HandCurler[i].transform.localRotation;
|
|
}
|
|
}
|
|
|
|
private void OnEnable()
|
|
{
|
|
m_TrackingType.OnValueChanged += UpdateTrackingType;
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
m_TrackingType.OnValueChanged -= UpdateTrackingType;
|
|
}
|
|
|
|
public override void OnDestroy()
|
|
{
|
|
base.OnDestroy();
|
|
if (IsOwner)
|
|
{
|
|
m_XRModalityManager.trackedHandModeStarted.RemoveListener(SwapToHands);
|
|
m_XRModalityManager.motionControllerModeStarted.RemoveListener(SwapToControllers);
|
|
}
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (!m_IsInitialized.Value || !NetworkManager.IsConnectedClient || NetworkManager.ShutdownInProgress) return;
|
|
|
|
if (trackingType == XRInputModalityManager.InputMode.TrackedHand)
|
|
{
|
|
switch (m_FidelityLevel)
|
|
{
|
|
case 0:
|
|
SyncAllFingerData();
|
|
break;
|
|
case 1:
|
|
SyncFingerCurl();
|
|
break;
|
|
case 2:
|
|
SyncFingerCurlLimited();
|
|
break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SyncControllerTracking();
|
|
}
|
|
}
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
base.OnNetworkSpawn();
|
|
|
|
if (IsOwner)
|
|
{
|
|
m_XROrigin = FindFirstObjectByType<XROrigin>();
|
|
if (m_XROrigin.TryGetComponent(out m_XRModalityManager))
|
|
{
|
|
SetupLocalHands();
|
|
}
|
|
|
|
SetupLocalFingerReferences();
|
|
}
|
|
else
|
|
{
|
|
ChangeControllerType(m_TrackingType.Value);
|
|
}
|
|
}
|
|
|
|
void UpdateTrackingType(XRInputModalityManager.InputMode old, XRInputModalityManager.InputMode current)
|
|
{
|
|
ChangeControllerType(current);
|
|
}
|
|
|
|
void SetupLocalHands()
|
|
{
|
|
m_LeftControllerTransformReference = m_XRModalityManager.leftController.transform;
|
|
m_RightControllerTransformReference = m_XRModalityManager.rightController.transform;
|
|
|
|
if (m_XRModalityManager.leftHand == null) //Rig doesn't have hands setup
|
|
{
|
|
m_LeftHandTransformReference = m_XRModalityManager.leftController.transform;
|
|
m_RightHandTransformReference = m_XRModalityManager.rightController.transform;
|
|
SetTrackingType(XRInputModalityManager.InputMode.MotionController);
|
|
}
|
|
else //Setup Hands and modality change listeners
|
|
{
|
|
m_LeftHandTransformReference = m_XRModalityManager.leftHand.GetComponentInChildren<XRHandSkeletonDriver>().rootTransform;
|
|
m_RightHandTransformReference = m_XRModalityManager.rightHand.GetComponentInChildren<XRHandSkeletonDriver>().rootTransform;
|
|
|
|
SetTrackingType(XRInputModalityManager.currentInputMode.Value);
|
|
|
|
m_XRModalityManager.trackedHandModeStarted.AddListener(SwapToHands);
|
|
m_XRModalityManager.motionControllerModeStarted.AddListener(SwapToControllers);
|
|
}
|
|
}
|
|
|
|
void SetupLocalFingerReferences()
|
|
{
|
|
XRInputModalityManager modalityMangager = FindFirstObjectByType<XRInputModalityManager>();
|
|
|
|
// Early out if hands are not setup
|
|
if (modalityMangager.leftHand == null)
|
|
{
|
|
// Set Default values for finger rotations
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
m_FingerCurlLeft.Add(0.0f);
|
|
m_FingerCurlRight.Add(0.0f);
|
|
}
|
|
|
|
m_IsInitialized.Value = true;
|
|
ChangeControllerType(XRInputModalityManager.InputMode.MotionController);
|
|
return;
|
|
}
|
|
|
|
|
|
XRHandSkeletonDriver localLeftHandSkeleton = modalityMangager.leftHand.GetComponentInChildren<XRHandSkeletonDriver>();
|
|
XRHandSkeletonDriver localRightHandSkeleton = modalityMangager.rightHand.GetComponentInChildren<XRHandSkeletonDriver>();
|
|
|
|
|
|
m_LocalHandFidelityOptions = new HandFidelityOption[2];
|
|
for (int i = 0; i < m_LocalHandFidelityOptions.Length; i++)
|
|
{
|
|
m_LocalHandFidelityOptions[i].fingerJoints = new FingerJoints[5];
|
|
|
|
// Loop through each finger and setup name and joints
|
|
for (int j = 0; j < m_LocalHandFidelityOptions[i].fingerJoints.Length; j++)
|
|
{
|
|
m_LocalHandFidelityOptions[i].fingerJoints[j].fingerName = m_HandCurler[i].handFidelityOptions[0].fingerJoints[j].fingerName;
|
|
m_LocalHandFidelityOptions[i].fingerJoints[j].jointTransformReferences = new List<JointToTransformReference>();
|
|
|
|
// Loop through each joint in the finger and setup the joint references
|
|
for (int k = 0; k < m_HandCurler[i].handFidelityOptions[0].fingerJoints[j].jointTransformReferences.Count; k++)
|
|
{
|
|
XRHandSkeletonDriver currentHandSkeletonDriver = i % 2 == 0 ? localLeftHandSkeleton : localRightHandSkeleton;
|
|
// Loop through each local hand and look up the joint reference
|
|
foreach (var localJoint in currentHandSkeletonDriver.jointTransformReferences)
|
|
{
|
|
if (m_HandCurler[i].handFidelityOptions[0].fingerJoints[j].jointTransformReferences[k].xrHandJointID == localJoint.xrHandJointID)
|
|
{
|
|
m_LocalHandFidelityOptions[i].fingerJoints[j].jointTransformReferences.Add(localJoint);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var fingerSync in m_LocalHandFidelityOptions[0].fingerJoints)
|
|
{
|
|
foreach (var joint in fingerSync.jointTransformReferences)
|
|
{
|
|
m_FingerRotationsLeft.Add(joint.jointTransform.eulerAngles);
|
|
}
|
|
}
|
|
|
|
foreach (var fingerSync in m_LocalHandFidelityOptions[1].fingerJoints)
|
|
{
|
|
foreach (var joint in fingerSync.jointTransformReferences)
|
|
{
|
|
m_FingerRotationsRight.Add(joint.jointTransform.eulerAngles);
|
|
}
|
|
}
|
|
|
|
|
|
// Set Default values for finger curl
|
|
foreach (var fingerSync in m_LocalHandFidelityOptions[0].fingerJoints)
|
|
{
|
|
m_FingerCurlLeft.Add(0.0f);
|
|
}
|
|
|
|
foreach (var fingerSync in m_LocalHandFidelityOptions[1].fingerJoints)
|
|
{
|
|
m_FingerCurlRight.Add(0.0f);
|
|
}
|
|
|
|
|
|
m_IsInitialized.Value = true;
|
|
|
|
SetFidelity(m_FidelityLevel);
|
|
}
|
|
|
|
public void ChangeControllerType(XRInputModalityManager.InputMode inputMode)
|
|
{
|
|
if (inputMode == XRInputModalityManager.InputMode.MotionController)
|
|
{
|
|
SetFidelity(2);
|
|
SetHandsToControllerOffset();
|
|
}
|
|
else
|
|
{
|
|
SetFidelity(m_FidelityLevel);
|
|
ResetHandsToStart();
|
|
}
|
|
}
|
|
|
|
void SetFidelity(int fidelity)
|
|
{
|
|
fidelity = Mathf.Clamp(fidelity, 0, 2);
|
|
|
|
m_HandCurler[0].fidelityLevel = fidelity;
|
|
m_HandCurler[1].fidelityLevel = fidelity;
|
|
|
|
m_HandCurler[0].useCurl = fidelity > 0;
|
|
m_HandCurler[1].useCurl = fidelity > 0;
|
|
}
|
|
|
|
void SyncAllFingerData()
|
|
{
|
|
if (IsOwner)
|
|
{
|
|
SetNetworkFingerRotations();
|
|
|
|
if (m_UpdateHandsLocally)
|
|
{
|
|
GetNetworkFingerRotations();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
GetNetworkFingerRotations();
|
|
}
|
|
}
|
|
|
|
void SetNetworkFingerRotations()
|
|
{
|
|
int currentIdx = 0;
|
|
for (int i = 0; i < m_LocalHandFidelityOptions[0].fingerJoints.Length; i++)
|
|
{
|
|
for (int j = 0; j < m_LocalHandFidelityOptions[0].fingerJoints[i].jointTransformReferences.Count; j++)
|
|
{
|
|
m_FingerRotationsLeft[currentIdx++] =
|
|
m_LocalHandFidelityOptions[0].fingerJoints[i].jointTransformReferences[j].jointTransform.eulerAngles;
|
|
|
|
}
|
|
}
|
|
|
|
currentIdx = 0;
|
|
for (int i = 0; i < m_LocalHandFidelityOptions[1].fingerJoints.Length; i++)
|
|
{
|
|
for (int j = 0; j < m_LocalHandFidelityOptions[1].fingerJoints[i].jointTransformReferences.Count; j++)
|
|
{
|
|
m_FingerRotationsRight[currentIdx++] =
|
|
m_LocalHandFidelityOptions[1].fingerJoints[i].jointTransformReferences[j].jointTransform.eulerAngles;
|
|
}
|
|
}
|
|
}
|
|
|
|
void GetNetworkFingerRotations()
|
|
{
|
|
int currentIdx = 0;
|
|
int hand = 0;
|
|
for (int i = 0; i < m_HandCurler[hand].handFidelityOptions[0].fingerJoints.Length; i++)
|
|
{
|
|
for (int j = 0; j < m_HandCurler[hand].handFidelityOptions[0].fingerJoints[i].jointTransformReferences.Count; j++)
|
|
{
|
|
m_HandCurler[hand].handFidelityOptions[0].fingerJoints[i].jointTransformReferences[j].jointTransform.rotation =
|
|
Quaternion.Slerp(m_HandCurler[hand].handFidelityOptions[0].fingerJoints[i].jointTransformReferences[j].jointTransform.rotation,
|
|
Quaternion.Euler(m_FingerRotationsLeft[currentIdx++]),
|
|
Time.deltaTime * m_FingerLerpSpeed);
|
|
}
|
|
}
|
|
|
|
currentIdx = 0;
|
|
hand = 1;
|
|
for (int i = 0; i < m_HandCurler[hand].handFidelityOptions[0].fingerJoints.Length; i++)
|
|
{
|
|
for (int j = 0; j < m_HandCurler[hand].handFidelityOptions[0].fingerJoints[i].jointTransformReferences.Count; j++)
|
|
{
|
|
m_HandCurler[hand].handFidelityOptions[0].fingerJoints[i].jointTransformReferences[j].jointTransform.rotation =
|
|
Quaternion.Slerp(m_HandCurler[hand].handFidelityOptions[0].fingerJoints[i].jointTransformReferences[j].jointTransform.rotation,
|
|
Quaternion.Euler(m_FingerRotationsRight[currentIdx++]),
|
|
Time.deltaTime * m_FingerLerpSpeed);
|
|
}
|
|
}
|
|
}
|
|
|
|
void SyncFingerCurl()
|
|
{
|
|
if (IsOwner)
|
|
{
|
|
SetNetworkCurl();
|
|
|
|
if (m_UpdateHandsLocally)
|
|
{
|
|
GetNetworkCurl();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
GetNetworkCurl();
|
|
}
|
|
}
|
|
|
|
void SetNetworkCurl()
|
|
{
|
|
for (int i = 0; i < m_LocalHandFidelityOptions[0].fingerJoints.Length; i++)
|
|
{
|
|
SetLocalNetworkFingerCurl(0, i, GetAverageX(0, i));
|
|
}
|
|
|
|
for (int i = 0; i < m_LocalHandFidelityOptions[1].fingerJoints.Length; i++)
|
|
{
|
|
SetLocalNetworkFingerCurl(1, i, GetAverageX(1, i));
|
|
}
|
|
}
|
|
|
|
void GetNetworkCurl()
|
|
{
|
|
for (int i = 0; i < m_HandCurler[0].handFidelityOptions[0].fingerJoints.Length; i++)
|
|
{
|
|
m_HandCurler[0].SetCurl(i, m_FingerCurlLeft[i]);
|
|
}
|
|
|
|
for (int i = 0; i < m_HandCurler[1].handFidelityOptions[0].fingerJoints.Length; i++)
|
|
{
|
|
m_HandCurler[1].SetCurl(i, m_FingerCurlRight[i]);
|
|
}
|
|
}
|
|
|
|
|
|
float GetAverageX(int hand, int finger)
|
|
{
|
|
float x = 0;
|
|
int digitCount = 4;
|
|
|
|
if (finger == 0) //Thumbs have 1 less joint
|
|
{
|
|
digitCount--;
|
|
}
|
|
|
|
for (int i = 1; i < digitCount; i++)
|
|
{
|
|
float currentX = m_LocalHandFidelityOptions[hand].fingerJoints[finger].jointTransformReferences[i].jointTransform.localEulerAngles.x;
|
|
if (currentX < 0 || currentX > 180)
|
|
{
|
|
currentX = 0;
|
|
}
|
|
x += currentX;
|
|
}
|
|
|
|
float avg = Mathf.Clamp(x / (digitCount - 1), 0, 100);
|
|
|
|
return avg / 100;
|
|
}
|
|
|
|
void SyncFingerCurlLimited()
|
|
{
|
|
if (IsOwner)
|
|
{
|
|
SetNetworkCurlLimited();
|
|
|
|
if (m_UpdateHandsLocally)
|
|
{
|
|
GetNetworkCurlLimited();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
GetNetworkCurlLimited();
|
|
}
|
|
}
|
|
|
|
void SetNetworkCurlLimited()
|
|
{
|
|
|
|
for (int i = 0; i < 2; i++)
|
|
{
|
|
SetLocalNetworkFingerCurl(0, i, GetAverageX(0, i));
|
|
}
|
|
|
|
for (int i = 0; i < 2; i++)
|
|
{
|
|
SetLocalNetworkFingerCurl(1, i, GetAverageX(1, i));
|
|
}
|
|
|
|
SetLocalNetworkFingerCurl(0, 2, GetAverageXCombined(0));
|
|
SetLocalNetworkFingerCurl(1, 2, GetAverageXCombined(1));
|
|
}
|
|
|
|
void GetNetworkCurlLimited()
|
|
{
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
m_HandCurler[0].SetCurl(i, m_FingerCurlLeft[i]);
|
|
}
|
|
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
m_HandCurler[1].SetCurl(i, m_FingerCurlRight[i]);
|
|
}
|
|
}
|
|
|
|
float GetAverageXCombined(int hand)
|
|
{
|
|
float x = 0;
|
|
|
|
int digitCount = 4;
|
|
int startFinger = 2;
|
|
int endFinger = 5;
|
|
|
|
int count = 0;
|
|
for (int i = startFinger; i < endFinger; i++)
|
|
{
|
|
for (int j = 1; j < digitCount; j++)
|
|
{
|
|
float currentX = m_LocalHandFidelityOptions[hand].fingerJoints[i].jointTransformReferences[j].jointTransform.localEulerAngles.x;
|
|
if (currentX < 0 || currentX > 180)
|
|
{
|
|
currentX = 0;
|
|
}
|
|
x += currentX;
|
|
count++;
|
|
}
|
|
}
|
|
|
|
float avg = Mathf.Clamp(x / count, 0, 100);
|
|
|
|
return avg / 100;
|
|
}
|
|
|
|
void SyncControllerTracking()
|
|
{
|
|
//TODO: Sync Controller Input and map to hand poses
|
|
if (IsOwner)
|
|
{
|
|
SetNetworkControllerFingerSync();
|
|
if (m_UpdateHandsLocally)
|
|
{
|
|
GetNetworkedControllerFingerSync();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
GetNetworkedControllerFingerSync();
|
|
}
|
|
}
|
|
|
|
void SetNetworkControllerFingerSync()
|
|
{
|
|
SetLocalNetworkFingerCurl(0, 0, m_ThumbTouchProperties[0].action?.ReadValue<float>() ?? 0.0f);
|
|
SetLocalNetworkFingerCurl(0, 1, m_TriggerInputProperties[0].action?.ReadValue<float>() ?? 0.0f);
|
|
SetLocalNetworkFingerCurl(0, 2, m_GripInputProperties[0].action?.ReadValue<float>() ?? 0.0f);
|
|
|
|
SetLocalNetworkFingerCurl(1, 0, m_ThumbTouchProperties[1].action?.ReadValue<float>() ?? 0.0f);
|
|
SetLocalNetworkFingerCurl(1, 1, m_TriggerInputProperties[1].action?.ReadValue<float>() ?? 0.0f);
|
|
SetLocalNetworkFingerCurl(1, 2, m_GripInputProperties[1].action?.ReadValue<float>() ?? 0.0f);
|
|
}
|
|
|
|
void SetLocalNetworkFingerCurl(int hand, int finger, float value)
|
|
{
|
|
if (hand == 0)
|
|
{
|
|
if (Mathf.Abs(m_FingerCurlLeft[finger] - value) > m_MinUpdateDelta)
|
|
m_FingerCurlLeft[finger] = value;
|
|
}
|
|
else
|
|
{
|
|
if (Mathf.Abs(m_FingerCurlRight[finger] - value) > m_MinUpdateDelta)
|
|
m_FingerCurlRight[finger] = value;
|
|
}
|
|
}
|
|
|
|
void GetNetworkedControllerFingerSync()
|
|
{
|
|
GetNetworkCurlLimited();
|
|
}
|
|
|
|
void SwapToHands()
|
|
{
|
|
SetTrackingType(XRInputModalityManager.InputMode.TrackedHand);
|
|
}
|
|
|
|
void SwapToControllers()
|
|
{
|
|
SetTrackingType(XRInputModalityManager.InputMode.MotionController);
|
|
}
|
|
|
|
public void SetTrackingType(XRInputModalityManager.InputMode trackingType)
|
|
{
|
|
m_TrackingType.Value = trackingType;
|
|
if (trackingType == XRInputModalityManager.InputMode.MotionController)
|
|
{
|
|
m_LeftHandOrigin = m_LeftControllerTransformReference;
|
|
m_RightHandOrigin = m_RightControllerTransformReference;
|
|
}
|
|
else
|
|
{
|
|
m_LeftHandOrigin = m_LeftHandTransformReference;
|
|
m_RightHandOrigin = m_RightHandTransformReference;
|
|
}
|
|
|
|
XRINetworkPlayer.LocalPlayer.SetHandOrigins(m_LeftHandOrigin, m_RightHandOrigin);
|
|
}
|
|
|
|
void ResetHandsToStart()
|
|
{
|
|
for (int i = 0; i < m_HandCurler.Length; i++)
|
|
{
|
|
m_HandCurler[i].transform.localPosition = m_handTrackedStartPose[i].position;
|
|
m_HandCurler[i].transform.localRotation = m_handTrackedStartPose[i].rotation;
|
|
}
|
|
}
|
|
|
|
void SetHandsToControllerOffset()
|
|
{
|
|
for (int i = 0; i < m_HandCurler.Length; i++)
|
|
{
|
|
m_HandCurler[i].transform.localPosition = m_HandControllerOffsets[i];
|
|
m_HandCurler[i].transform.localRotation = Quaternion.Euler(m_HandControllerEulerOffsets[i]);
|
|
}
|
|
}
|
|
}
|
|
}
|