/*
* 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
///
/// Called once when editor opens to register use of the SDK.
///
[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
///
/// Low-level API for the XR Haptics SDK runtime.
///
///
///
/// 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 HapticClipPlayer
/// and HapticClip should be used instead.
///
/// In a nutshell, it wraps the C# Native SDK bindings of the Oculus.Haptics.Ffi 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.
///
public class Haptics : IDisposable
{
protected static Haptics instance;
///
/// Haptics SDK name used for Telemetry.
///
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;
///
/// Whether PCM haptics are available and enabled for the current runtime.
///
///
///
/// 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.
///
public static bool IsPCMHaptics { get; private set; } = false;
///
/// Returns the singleton instance of Haptics: either existing or new.
///
///
///
/// The Haptics class provides access to haptic feedback functionality. This property returns
/// the singleton instance of Haptics,creating a new instance if one does not already exist.
///
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;
}
}
///
/// 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.
///
///
///true if the current platform is supported, false otherwise
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
}
///
/// 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.
///
///
///true if the PCM haptics extension is enabled, false otherwise
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
}
///
/// 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.
///
///
///
/// This method is specific to OpenXR on Windows and uses the SendHapticImpulse() API to play haptics on the specified controller.
///
///
/// Context pointer
/// Controller where the haptics should play
/// Duration of the vibration
/// Amplitude of the vibration
[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
}
///
/// The constructor is protected to ensure that a class instance cannot be created directly and all consuming
/// code must go through the Instance property.
/// This design pattern ensures a singleton-like behavior, where only one instance of the Haptics class exists
/// throughout the application.
///
protected Haptics()
{
}
///
/// 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.
///
///
/// true if it was possible to ensure initialization; false otherwise.
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;
}
///
/// 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.
///
///
///true if the haptics runtime is initialized, false otherwise
private static bool IsInitialized()
{
if (Ffi.Failed(Ffi.initialized(out var isInitialized)))
{
Debug.LogError("Failed to get initialization state");
return false;
}
return isInitialized;
}
///
/// Loads haptic data from a JSON string.
///
///
/// The UTF-8-encoded JSON string containing haptic data (.haptic format).
/// A haptic clip ID if the data is parsed successfully; Ffi.InvalidId otherwise.
/// If the format of the haptic clip data is not valid; or the data is not UTF-8 encoded.
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;
}
///
/// Releases ownership of a loaded haptic clip.
///
///
///
/// 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.
///
///
/// The ID of the haptic clip to be released.
/// true if the clip was released successfully; false if
/// the clip was already released or the call was unsuccessful.
public bool ReleaseClip(int clipId)
{
return Ffi.Succeeded(Ffi.release_clip(clipId));
}
///
/// Creates a haptic clip player.
///
///
///
/// To play a haptic clip with a created player, the haptic clip must first be loaded using LoadClip(),
/// assigned to the player using SetHapticPlayerClip(), and finally playback is started using
/// PlayHapticPlayer().
///
///
/// The player ID, if the player was created successfully; Ffi.InvalidId if something went wrong.
public int CreateHapticPlayer()
{
int playerId = Ffi.InvalidId;
Ffi.create_player(out playerId);
return playerId;
}
///
/// 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 LoadClip() (see above).
///
///
/// The ID of the clip player.
/// The ID of the haptic clip to be released.
/// If the player ID was invalid.
/// If the clip ID was invalid.
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}.");
}
}
///
/// Starts playback on the player with the specified player ID on the specified controller.
///
///
/// The ID of the clip player to start playback on.
/// The controller to play on. Can be Left, Right or Both controllers.
/// If the player ID was invalid.
/// If an invalid controller was selected for playback.
/// If the player has no clip loaded.
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.");
};
}
///
/// Pauses playback on the player with the specified player ID.
///
///
/// The ID of the clip player to pause playback on.
/// If the player ID was invalid.
/// If the player has no clip loaded.
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.");
};
}
///
/// Resumes playback on the player with the specified player ID.
///
///
/// The ID of the clip player to resume playback on.
/// If the player ID was invalid.
/// If the player has no clip loaded.
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.");
};
}
///
/// Stops playback that was previously started with PlayHapticPlayer().
///
///
/// The ID of the clip player to stop playback on.
/// If the player ID was invalid.
/// If the player has no clip loaded.
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.");
};
}
///
/// Seeks the current playback position.
///
///
/// The ID of the clip player to move the playback position on.
/// The target playback position in seconds.
/// If the player ID was invalid.
/// If the player has no clip loaded to seek.
/// 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 .
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.");
};
}
///
/// Returns the duration of the loaded haptic clip.
///
///
/// The ID of the haptic clip to be queried for its duration.
/// The duration of the haptic clip in seconds if the call was successful; 0.0 otherwise.
/// If the clip ID was invalid.
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;
}
///
/// Enables or disables a clip player's loop state.
///
///
/// The ID of the clip player.
/// true if the clip player should loop; false to disable looping.
/// If the player ID was invalid.
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}.");
}
}
///
/// Gets a clip player's loop state (if it loops or not).
///
///
/// The ID of the clip player.
/// The current loop state, if getting the state was successful, and the default value of false otherwise.
/// If the player ID was invalid.
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;
}
///
/// Sets the clip player's amplitude.
///
///
/// The ID of the clip player.
/// 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.
/// If the player ID was invalid.
/// If the amplitude argument is out of range (has to be non-negative).
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."
);
}
}
///
/// Get the clip player's amplitude.
///
///
/// The ID of the clip player.
/// The current player amplitude, if getting amplitude was successful; and the default value (1.0) otherwise.
/// If the player ID was invalid.
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;
}
///
/// Sets the clip player's frequency shift.
///
///
/// ID of the clip player.
/// A value between -1.0 and 1.0 (inclusive). Values outside this range will cause an exception.
/// If the player ID was invalid.
/// If the frequency shift amount is out of range.
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)."
);
}
}
///
/// Gets the clip player's current frequency shift based on it's player ID.
///
///
/// The ID of the clip player.
/// The current player frequency shift if getting frequency shift was successful; the default value (0.0) otherwise.
/// If the player ID was invalid.
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;
}
///
/// 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.
///
///
/// The value to be scaled.
/// The lower limit of the source range.
/// The upper limit of the source range.
/// The lower limit of the target range.
/// The upper limit of the target range.
/// If the input value is out of range.
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)."
);
}
}
///
/// 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.
///
///
/// ID of the clip player.
/// A value between 0 and 255 (inclusive). Values outside this range will cause an exception.
/// If the player ID was invalid.
/// If the priority value is out of range.
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)."
);
}
}
///
/// Gets the clip player's current playback priority value based on its player ID.
///
///
/// The ID of the clip player.
/// The current priority value if successful; the default value (128) otherwise.
/// If the player ID was invalid.
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);
}
///
/// Releases a clip player that was previously created with CreateHapticPlayer().
///
///
/// ID of the clip player to be released.
/// true if release was successful; false if the player does not exist,
/// was already released, or the call was unsuccessful.
public bool ReleaseHapticPlayer(int playerId)
{
return Ffi.Succeeded(Ffi.release_player(playerId));
}
///
/// 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 HapticClipPlayers and HapticClips being released from memory.
///
///
///
/// 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 ~Haptics on shutdown.
/// However, if you have a particular reason to release it explicitly, call Dispose() to do
/// so. If you need to use the haptics runtime again after having released it simply create a new
/// HapticClipPlayer and a new runtime will be instantiated implicitly.
///
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
///Releases the haptics runtime and its associated resources.
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;
}
}
///
/// Haptics should only be garbage collected during shutdown. Relying on the garbage collector
/// to destroy and instance of Haptics with the intention of creating a new one afterwards
/// is likely to produce undefined behaviour. For this, use the Dispose() method instead.
///
~Haptics()
{
Dispose(false);
}
}
}