VR4RoboticArm2/VR4RoboticArm/Library/PackageCache/com.meta.xr.sdk.haptics/Runtime/Haptics.cs
IonutMocanu d7aba243a2 Main
2025-09-08 11:04:02 +03:00

713 lines
31 KiB
C#

/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
* All rights reserved.
*
* Licensed under the Oculus SDK License Agreement (the "License");
* you may not use the Oculus SDK except in compliance with the License,
* which is provided at the time of installation or download, or which
* otherwise accompanies this software in either electronic or hard copy form.
*
* You may obtain a copy of the License at
*
* https://developer.oculus.com/licenses/oculussdk/
*
* Unless required by applicable law or agreed to in writing, the Oculus SDK
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @lint-ignore-every LICENSELINT
using System;
using System.Threading;
using AOT;
using UnityEditor;
using UnityEngine;
#if (UNITY_STANDALONE_WIN && USING_XR_SDK_OPENXR)
using UnityEngine.XR;
using UnityEngine.XR.OpenXR;
#endif
namespace Oculus.Haptics
{
#if UNITY_EDITOR
/// <summary>
/// Called once when editor opens to register use of the SDK.
/// </summary>
[InitializeOnLoad]
static class StartupTelemetry
{
static StartupTelemetry()
{
if (SessionState.GetBool("FirstInitDone", false))
{
return;
}
OVRPlugin.SendUnifiedEvent(OVRPlugin.Bool.True, Haptics.HapticsSDKTelemetryName, "haptics_sdk_in_project", "");
SessionState.SetBool("FirstInitDone", true);
}
}
#endif
/// <summary>
/// Low-level API for the XR Haptics SDK runtime.
/// </summary>
///
/// <remarks>
/// This class is provided where low-level control of the XR Haptics SDK runtime is required.
/// Most applications probably do not require this class and <c>HapticClipPlayer</c>
/// and <c>HapticClip</c> should be used instead.
///
/// In a nutshell, it wraps the C# Native SDK bindings of the <c>Oculus.Haptics.Ffi</c> class
/// to be used by C# abstractions. None of the methods here are thread-safe and should
/// only be called from the main (Unity) thread. Calling these methods from a secondary
/// thread can cause undefined behaviour and memory leaks.
/// </remarks>
public class Haptics : IDisposable
{
protected static Haptics instance;
/// <summary>
/// Haptics SDK name used for Telemetry.
/// </summary>
public const string HapticsSDKTelemetryName = "haptics_sdk";
// A synchronization context is needed for the PlayCallback to call InputDevices.SendHapticImpulse(),
// as the PlayCallback function is being called from a thread that is not Unity's main
private static SynchronizationContext syncContext;
/// <summary>
/// Whether PCM haptics are available and enabled for the current runtime.
/// </summary>
///
/// <remarks>
/// PCM haptics allow for high-quality haptic feedback. This property indicates whether PCM haptics
/// are supported by the current runtime and whether they are enabled.
/// </remarks>
public static bool IsPCMHaptics { get; private set; } = false;
/// <summary>
/// Returns the singleton instance of <c>Haptics</c>: either existing or new.
/// </summary>
///
/// <remarks>
/// The <c>Haptics</c> class provides access to haptic feedback functionality. This property returns
/// the singleton instance of <c>Haptics</c>,creating a new instance if one does not already exist.
/// </remarks>
public static Haptics Instance
{
get
{
if (!IsSupportedPlatform())
{
Debug.LogError($"Error: This platform is not supported for haptics");
instance = null;
return null;
}
instance ??= new Haptics();
// Ensure that the underlying runtime is initialized.
if (!EnsureInitialized())
{
instance = null;
}
return instance;
}
}
/// <summary>
/// Determines whether the current platform is supported by the Haptics SDK, returning true for standalone Quest builds,
/// Link to Quest on Windows, and other PCVR devices on Windows.
/// </summary>
///
///<returns><c>true</c> if the current platform is supported, <c>false</c> otherwise</returns>
private static bool IsSupportedPlatform()
{
// Standalone Quest builds, Link to Quest and other PCVR devices on Windows.
#if ((UNITY_ANDROID && !UNITY_EDITOR) || UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN)
return true;
#else
return false;
#endif
}
/// <summary>
/// Checks if the PCM haptics extension is enabled on the current platform.
/// This method is specific to OpenXR on Windows and returns true if the XR_FB_haptic_pcm extension is enabled.
/// On other platforms, it always returns true.
/// </summary>
///
///<returns><c>true</c> if the PCM haptics extension is enabled, <c>false</c> otherwise</returns>
private static bool IsPcmHapticsExtensionEnabled()
{
#if (UNITY_STANDALONE_WIN && USING_XR_SDK_OPENXR)
foreach (string feature in OpenXRRuntime.GetEnabledExtensions())
{
if (feature.Equals("XR_FB_haptic_pcm"))
{
return true;
}
}
return false;
#else
return true;
#endif
}
/// <summary>
/// Plays a haptic sample through Unity's SendHapticImpulse() API.
/// This function is called internally as a play callback on non-Quest devices that do not support the PCM API.
/// </summary>
///
/// <remarks>
/// This method is specific to OpenXR on Windows and uses the <c>SendHapticImpulse()</c> API to play haptics on the specified controller.
/// </remarks>
///
/// <param name="context">Context pointer</param>
/// <param name="controller">Controller where the haptics should play</param>
/// <param name="duration">Duration of the vibration</param>
/// <param name="amplitude">Amplitude of the vibration</param>
[AOT.MonoPInvokeCallback(typeof(Ffi.HapticsSdkPlayCallback))]
private static void PlayCallback(IntPtr context, Ffi.Controller controller, float duration, float amplitude)
{
#if (UNITY_STANDALONE_WIN && USING_XR_SDK_OPENXR)
syncContext.Post(_ =>
{
switch (controller)
{
case Ffi.Controller.Left:
InputDevices.GetDeviceAtXRNode(XRNode.LeftHand).SendHapticImpulse(0, amplitude, duration);
break;
case Ffi.Controller.Right:
InputDevices.GetDeviceAtXRNode(XRNode.RightHand).SendHapticImpulse(0, amplitude, duration);
break;
}
}, null);
#endif
}
/// <summary>
/// The constructor is protected to ensure that a class instance cannot be created directly and all consuming
/// code must go through the <c>Instance</c> property.
/// This design pattern ensures a singleton-like behavior, where only one instance of the Haptics class exists
/// throughout the application.
/// </summary>
protected Haptics()
{
}
/// <summary>
/// Ensures that the haptics runtime is initialized for supported configurations.
/// This method checks if the haptics runtime is already initialized, and if not, attempts to initialize it using the OVRPlugin backend or the callback backend.
/// </summary>
///
/// <returns><c>true</c> if it was possible to ensure initialization; <c>false</c> otherwise.</returns>
private static bool EnsureInitialized()
{
if (IsInitialized())
{
return true;
}
if (IsPcmHapticsExtensionEnabled() && Ffi.Succeeded(Ffi.initialize_with_ovr_plugin("Unity", Application.unityVersion, "77.0.0-mainline.0")))
{
Debug.Log("Initialized with OVRPlugin backend");
IsPCMHaptics = true;
return true;
}
// We are not using a Quest link runtime, so let's initialize simple haptics via the callback backend
if (Ffi.Succeeded(Ffi.initialize_with_callback_backend(IntPtr.Zero, PlayCallback)))
{
Debug.Log("Initialized with callback backend");
syncContext = SynchronizationContext.Current;
return true;
}
Debug.LogError($"Error: {Ffi.error_message()}");
return false;
}
/// <summary>
/// Checks if the haptics runtime is already initialized.
/// This method queries the initialization state of the haptics runtime and returns true if it is initialized, false otherwise.
/// </summary>
///
///<returns><c>true</c> if the haptics runtime is initialized, <c>false</c> otherwise</returns>
private static bool IsInitialized()
{
if (Ffi.Failed(Ffi.initialized(out var isInitialized)))
{
Debug.LogError("Failed to get initialization state");
return false;
}
return isInitialized;
}
/// <summary>
/// Loads haptic data from a JSON string.
/// </summary>
///
/// <param name="clipJson">The UTF-8-encoded JSON string containing haptic data (.haptic format).</param>
/// <returns>A haptic clip ID if the data is parsed successfully; Ffi.InvalidId otherwise.</returns>
/// <exception cref="FormatException">If the format of the haptic clip data is not valid; or the data is not UTF-8 encoded.</exception>
public int LoadClip(string clipJson)
{
int clipId = Ffi.InvalidId;
switch (Ffi.load_clip(clipJson, out clipId))
{
case Ffi.Result.LoadClipFailed:
throw new FormatException($"Invalid format for clip: {clipJson}.");
case Ffi.Result.InvalidUtf8:
throw new FormatException($"Invalid UTF8 encoding for clip: {clipJson}.");
}
return clipId;
}
/// <summary>
/// Releases ownership of a loaded haptic clip.
/// </summary>
///
/// <remarks>
/// Releasing the clip means that the API no longer maintains a handle to the clip.
/// However resources won't be freed until all clip players that are playing the clip have
/// also been destroyed.
/// </remarks>
///
/// <param name="clipId">The ID of the haptic clip to be released.</param>
/// <returns><c>true</c> if the clip was released successfully; <c>false</c> if
/// the clip was already released or the call was unsuccessful.</returns>
public bool ReleaseClip(int clipId)
{
return Ffi.Succeeded(Ffi.release_clip(clipId));
}
/// <summary>
/// Creates a haptic clip player.
/// </summary>
///
/// <remarks>
/// To play a haptic clip with a created player, the haptic clip must first be loaded using <c>LoadClip()</c>,
/// assigned to the player using <c>SetHapticPlayerClip()</c>, and finally playback is started using
/// <c>PlayHapticPlayer()</c>.
/// </remarks>
///
/// <returns>The player ID, if the player was created successfully; Ffi.InvalidId if something went wrong.</returns>
public int CreateHapticPlayer()
{
int playerId = Ffi.InvalidId;
Ffi.create_player(out playerId);
return playerId;
}
/// <summary>
/// Sets the clip that is used by the given player.
///
/// If the player is currently playing, it will stop. Other properties like amplitude, frequency
/// shift, looping and priority are kept.
///
/// The clip must have been previously loaded by calling <c>LoadClip()</c> (see above).
/// </summary>
///
/// <param name="playerId">The ID of the clip player.</param>
/// <param name="clipId">The ID of the haptic clip to be released.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
/// <exception cref="ArgumentException">If the clip ID was invalid.</exception>
public void SetHapticPlayerClip(int playerId, int clipId)
{
switch (Ffi.player_set_clip(playerId, clipId))
{
case Ffi.Result.PlayerIdInvalid:
throw new ArgumentException($"Invalid player ID: {playerId}.");
case Ffi.Result.ClipIdInvalid:
throw new ArgumentException($"Invalid clipId: {clipId}.");
}
}
/// <summary>
/// Starts playback on the player with the specified player ID on the specified controller.
/// </summary>
///
/// <param name="playerId">The ID of the clip player to start playback on.</param>
/// <param name="controller">The controller to play on. Can be <c>Left</c>, <c>Right</c> or <c>Both</c> controllers.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
/// <exception cref="ArgumentException">If an invalid controller was selected for playback.</exception>
/// <exception cref="InvalidOperationException">If the player has no clip loaded.</exception>
public void PlayHapticPlayer(int playerId, Controller controller)
{
Ffi.Controller ffiController = Utils.ControllerToFfiController(controller);
switch (Ffi.player_play(playerId, ffiController))
{
case Ffi.Result.PlayerIdInvalid:
throw new ArgumentException($"Invalid player ID: {playerId}.");
case Ffi.Result.NoClipLoaded:
throw new InvalidOperationException($"Player with ID {playerId} has no clip loaded.");
};
}
/// <summary>
/// Pauses playback on the player with the specified player ID.
/// </summary>
///
/// <param name="playerId">The ID of the clip player to pause playback on.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
/// <exception cref="InvalidOperationException">If the player has no clip loaded.</exception>
public void PauseHapticPlayer(int playerId)
{
switch (Ffi.player_pause(playerId))
{
case Ffi.Result.PlayerIdInvalid:
throw new ArgumentException($"Invalid player ID: {playerId}.");
case Ffi.Result.NoClipLoaded:
throw new InvalidOperationException($"Player with ID {playerId} has no clip loaded.");
};
}
/// <summary>
/// Resumes playback on the player with the specified player ID.
/// </summary>
///
/// <param name="playerId">The ID of the clip player to resume playback on.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
/// <exception cref="InvalidOperationException">If the player has no clip loaded.</exception>
public void ResumeHapticPlayer(int playerId)
{
switch (Ffi.player_resume(playerId))
{
case Ffi.Result.PlayerIdInvalid:
throw new ArgumentException($"Invalid player ID: {playerId}.");
case Ffi.Result.NoClipLoaded:
throw new InvalidOperationException($"Player with ID {playerId} has no clip loaded.");
};
}
/// <summary>
/// Stops playback that was previously started with <c>PlayHapticPlayer()</c>.
/// </summary>
///
/// <param name="playerId">The ID of the clip player to stop playback on.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
/// <exception cref="InvalidOperationException">If the player has no clip loaded.</exception>
public void StopHapticPlayer(int playerId)
{
switch (Ffi.player_stop(playerId))
{
case Ffi.Result.PlayerIdInvalid:
throw new ArgumentException($"Invalid player ID: {playerId}.");
case Ffi.Result.NoClipLoaded:
throw new InvalidOperationException($"Player with ID {playerId} has no clip loaded.");
};
}
/// <summary>
/// Seeks the current playback position.
/// </summary>
///
/// <param name="playerId">The ID of the clip player to move the playback position on.</param>
/// <param name="time">The target playback position in seconds.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
/// <exception cref="InvalidOperationException">If the player has no clip loaded to seek.</exception>
/// <exception cref="ArgumentOutOfRangeException">If the target playback position is out of range.
/// A valid playback position needs to be on a range of 0.0 to the currently loaded clip's duration in seconds.
/// To get the duration of the currently loaded clip, see <see cref="GetClipDuration"/>.</exception>
public void SeekPlaybackPositionHapticPlayer(int playerId, float time)
{
switch (Ffi.player_seek(playerId, time))
{
case Ffi.Result.PlayerIdInvalid:
throw new ArgumentException($"Invalid player ID: {playerId}.");
case Ffi.Result.NoClipLoaded:
throw new InvalidOperationException($"Player with ID {playerId} has no clip loaded.");
case Ffi.Result.PlayerInvalidSeekPosition:
throw new ArgumentOutOfRangeException($"Invalid time: {time} for player {playerId}." +
"Make sure the value is positive and within the playback duration of the currently loaded clip.");
};
}
/// <summary>
/// Returns the duration of the loaded haptic clip.
/// </summary>
///
/// <param name="clipId">The ID of the haptic clip to be queried for its duration.</param>
/// <returns>The duration of the haptic clip in seconds if the call was successful; 0.0 otherwise.</returns>
/// <exception cref="ArgumentException">If the clip ID was invalid.</exception>
public float GetClipDuration(int clipId)
{
float clipDuration = 0.0f;
if (Ffi.Result.ClipIdInvalid == Ffi.clip_duration(clipId, out clipDuration))
{
throw new ArgumentException($"Invalid clip ID: {clipId}.");
}
return clipDuration;
}
/// <summary>
/// Enables or disables a clip player's loop state.
/// </summary>
///
/// <param name="playerId">The ID of the clip player.</param>
/// <param name="enabled"><c>true</c> if the clip player should loop; <c>false</c> to disable looping.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
public void LoopHapticPlayer(int playerId, bool enabled)
{
if (Ffi.Result.PlayerIdInvalid == Ffi.player_set_looping_enabled(playerId, enabled))
{
throw new ArgumentException($"Invalid player ID: {playerId}.");
}
}
/// <summary>
/// Gets a clip player's loop state (if it loops or not).
/// </summary>
///
/// <param name="playerId">The ID of the clip player.</param>
/// <returns>The current loop state, if getting the state was successful, and the default value of <c>false</c> otherwise.</returns>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
public bool IsHapticPlayerLooping(int playerId)
{
bool playerLoopState = false;
if (Ffi.Result.PlayerIdInvalid == Ffi.player_looping_enabled(playerId, out playerLoopState))
{
throw new ArgumentException($"Invalid player ID: {playerId}.");
}
return playerLoopState;
}
/// <summary>
/// Sets the clip player's amplitude.
/// </summary>
///
/// <param name="playerId">The ID of the clip player.</param>
/// <param name="amplitude">A positive integer value for the amplitude of the clip player.
/// All individual amplitudes within the clip are scaled by this value. Each individual value
/// is clipped to one.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
/// <exception cref="ArgumentOutOfRangeException">If the amplitude argument is out of range (has to be non-negative).</exception>
public void SetAmplitudeHapticPlayer(int playerId, float amplitude)
{
switch (Ffi.player_set_amplitude(playerId, amplitude))
{
case Ffi.Result.PlayerIdInvalid:
throw new ArgumentException($"Invalid player ID: {playerId}.");
case Ffi.Result.PlayerInvalidAmplitude:
throw new ArgumentOutOfRangeException(
$"Invalid amplitude: {amplitude} for player {playerId}." +
"Make sure the value is non-negative."
);
}
}
/// <summary>
/// Get the clip player's amplitude.
/// </summary>
///
/// <param name="playerId">The ID of the clip player.</param>
/// <returns>The current player amplitude, if getting amplitude was successful; and the default value (1.0) otherwise.</returns>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
public float GetAmplitudeHapticPlayer(int playerId)
{
float playerAmplitude = 1.0f;
if (Ffi.Result.PlayerIdInvalid == Ffi.player_amplitude(playerId, out playerAmplitude))
{
throw new ArgumentException($"Invalid player ID: {playerId}.");
}
return playerAmplitude;
}
/// <summary>
/// Sets the clip player's frequency shift.
/// </summary>
///
/// <param name="playerId">ID of the clip player.</param>
/// <param name="amount">A value between -1.0 and 1.0 (inclusive). Values outside this range will cause an exception.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
/// <exception cref="ArgumentOutOfRangeException">If the frequency shift amount is out of range.</exception>
public void SetFrequencyShiftHapticPlayer(int playerId, float amount)
{
switch (Ffi.player_set_frequency_shift(playerId, amount))
{
case Ffi.Result.PlayerIdInvalid:
throw new ArgumentException($"Invalid player ID: {playerId}.");
case Ffi.Result.PlayerInvalidFrequencyShift:
throw new ArgumentOutOfRangeException(
$"Invalid frequency shift amount: {amount} for player {playerId}." +
"Make sure the value is on the range -1.0 to 1.0 (inclusive)."
);
}
}
/// <summary>
/// Gets the clip player's current frequency shift based on it's player ID.
/// </summary>
///
/// <param name="playerId">The ID of the clip player.</param>
/// <returns>The current player frequency shift if getting frequency shift was successful; the default value (0.0) otherwise.</returns>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
public float GetFrequencyShiftHapticPlayer(int playerId)
{
float playerFrequencyShift = 0.0f;
if (Ffi.Result.PlayerIdInvalid == Ffi.player_frequency_shift(playerId, out playerFrequencyShift))
{
throw new ArgumentException($"Invalid player ID: {playerId}.");
}
return playerFrequencyShift;
}
/// <summary>
/// A wrapper for Utils.Map(), used specifically for scaling priority values.
/// We make sure to catch if casting to uint overflows. This is only expected to happen when the user enters a
/// value outside of the expected priority range (i.e. 255+), thus we throw an exception to provide guidance.
/// </summary>
///
/// <param name="input">The value to be scaled.</param>
/// <param name="inMin">The lower limit of the source range.</param>
/// <param name="inMax">The upper limit of the source range.</param>
/// <param name="outMin">The lower limit of the target range.</param>
/// <param name="outMax">The upper limit of the target range.</param>
/// <exception cref="ArgumentException">If the input value is out of range.</exception>
private static uint MapPriority(uint input, int inMin, int inMax, int outMin, int outMax)
{
try
{
checked
{
float mappedValue = Utils.Map((int)input, inMin, inMax, outMin, outMax);
return (uint)Math.Round(mappedValue);
}
}
catch (OverflowException)
{
throw new ArgumentOutOfRangeException(
$"Invalid priority value: {input}. " +
"Make sure the value is within the range 0 to 255 (inclusive)."
);
}
}
/// <summary>
/// Sets the clip player's current playback priority value based on its player ID.
/// The priority values range from 0 (high priority) to 255 (low priority), with 128 being the default.
/// </summary>
///
/// <param name="playerId">ID of the clip player.</param>
/// <param name="value">A value between 0 and 255 (inclusive). Values outside this range will cause an exception.</param>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
/// <exception cref="ArgumentException">If the priority value is out of range.</exception>
public void SetPriorityHapticPlayer(int playerId, uint value)
{
// The native library takes values from 0 (low priority) to 1024 (high priority),
// while the Haptics SDK for Unity uses 0 (high priority) to 255 (low priority).
switch (Ffi.player_set_priority(playerId, MapPriority(value, 0, 255, 1024, 0)))
{
case Ffi.Result.PlayerIdInvalid:
throw new ArgumentException($"Invalid player ID: {playerId}.");
case Ffi.Result.PlayerInvalidPriority:
throw new ArgumentOutOfRangeException(
$"Invalid priority value: {value} for player {playerId}. " +
"Make sure the value is within the range 0 to 255 (inclusive)."
);
}
}
/// <summary>
/// Gets the clip player's current playback priority value based on its player ID.
/// </summary>
///
/// <param name="playerId">The ID of the clip player.</param>
/// <returns>The current priority value if successful; the default value (128) otherwise.</returns>
/// <exception cref="ArgumentException">If the player ID was invalid.</exception>
public uint GetPriorityHapticPlayer(int playerId)
{
uint playerPriority = 128;
if (Ffi.Result.PlayerIdInvalid == Ffi.player_priority(playerId, out playerPriority))
{
throw new ArgumentException($"Invalid player ID: {playerId}.");
}
// The native library takes values from 0 (low priority) to 1024 (high priority),
// while the Haptics SDK for Unity uses 0 (high priority) to 255 (low priority).
return MapPriority(playerPriority, 0, 1024, 255, 0);
}
/// <summary>
/// Releases a clip player that was previously created with <c>CreateHapticPlayer()</c>.
/// </summary>
///
/// <param name="playerId">ID of the clip player to be released.</param>
/// <returns><c>true</c> if release was successful; <c>false</c> if the player does not exist,
/// was already released, or the call was unsuccessful. </returns>
public bool ReleaseHapticPlayer(int playerId)
{
return Ffi.Succeeded(Ffi.release_player(playerId));
}
/// <summary>
/// Call this to explicitly release the haptics runtime.
/// This method has the haptics runtime released while ensuring that the garbage collector doesn't kick in.,
/// which will also result in any loaded <c>HapticClipPlayer</c>s and <c>HapticClip</c>s being released from memory.
/// </summary>
///
/// <remarks>
/// In general, you shouldn't need to explicitly release the haptics runtime as it is intended to run for the
/// duration of your application and gets released via <c>~Haptics</c> on shutdown.
/// However, if you have a particular reason to release it explicitly, call <c>Dispose()</c> to do
/// so. If you need to use the haptics runtime again after having released it simply create a new
/// <c>HapticClipPlayer</c> and a new runtime will be instantiated implicitly.
/// </remarks>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
///<summary>Releases the haptics runtime and its associated resources.</summary>
protected virtual void Dispose(bool disposing)
{
if (instance is not null)
{
if (IsInitialized() && Ffi.Failed(Ffi.uninitialize()))
{
Debug.LogError($"Error: {Ffi.error_message()}");
}
instance = null;
}
}
/// <summary>
/// <c>Haptics</c> should only be garbage collected during shutdown. Relying on the garbage collector
/// to destroy and instance of <c>Haptics</c> with the intention of creating a new one afterwards
/// is likely to produce undefined behaviour. For this, use the <c>Dispose()</c> method instead.
/// </summary>
~Haptics()
{
Dispose(false);
}
}
}