/* * 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); } } }