Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

108 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RichHaptic

openupm License: MIT Unity 6000.3+

A haptics package for Unity that talks to Core Haptics and VibrationEffect directly. No compiled native blob, no third-party runtime, no dependencies — not even TextMeshPro in the core assembly.

The thing it is built around is continuous feedback you can modulate while it plays: a bowstring that tightens under your finger, an engine that revs with the throttle. Most free haptics packages only fire preset taps. This one does that too, but the live path is the reason it exists.

It deliberately does not do gamepad rumble, looping, seeking, or ship a library of authored clips. If you need those, see the comparison near the bottom.

Install

Via OpenUPM:

openupm add com.acharad.richhaptic

Or Package Manager → Add package from git URL:

https://github.com/Acharad/RichHaptic.git?path=src/RichHaptic/Assets/RichHaptic

A demo scene comes with it as an optional sample, imported from the package's page in Package Manager. It needs TextMeshPro, which ships with Unity.

Usage

Everything goes through one static class. No setup, no prefab to drop in a scene, no initialisation call.

using RichHaptic;
using RichHaptic.Data;

Haptics.PlayPreset(PresetType.Success);

Nine presets: Selection, Success, Warning, Failure, and five impacts — LightImpact through HeavyImpact, plus RigidImpact and SoftImpact.

On iOS these are the system's own feedback generators, so they feel like the rest of the OS and stay right as Apple retunes them per device. Android has no equivalent and plays an authored approximation. Two iOS consequences worth knowing: a system haptic cannot be cancelled, so Stop() will not cut a preset short, and OutputLevel scales the five impacts but not Selection/Success/Warning/Failure — those generators take no intensity. Setting it to 0 still silences everything.

Continuous feedback

Start a session, push new values into it as often as you like, stop it when you're done.

void OnDrawStart()          => Haptics.StartContinuous(intensity: 0.2f, sharpness: 0.5f);
void OnDrawUpdate(float t)  => Haptics.UpdateContinuous(t, 0.5f);   // safe every frame
void OnRelease()            => Haptics.StopContinuous();

UpdateContinuous allocates nothing, so calling it from Update is fine. What it feels like differs by platform, and that difference is documented rather than papered over — see the table below.

How long does it run? There is no duration parameter. A session runs until you call StopContinuous, which is the whole point of it — you rarely know in advance how long the player will hold the bowstring.

Underneath, both platforms need a finite number, so there is a ceiling: 100 seconds on iOS, 30 on Android. Reaching one ends the session silently. On Android every UpdateContinuous sends a fresh segment and so restarts the clock, which means a session you are actually modulating never gets near it. A session left running untouched will.

If you do know the length up front, PlayConstant is the right call instead — one pulse, fixed strength, and a duration you name:

Haptics.PlayConstant(intensity: 0.8f, sharpness: 0.5f, duration: 0.4f);

Clips

A clip is a ScriptableObject you edit in the Inspector: a list of events, each with a type, a start time, an intensity and a sharpness. Create → RichHaptic → Haptic Clip.

[SerializeField] private HapticClip explosion;

void OnHit() => Haptics.PlayClip(explosion);

The demo sample ships three of these — a heartbeat, a charge-up, a rising four-beat hit — with a button for each. Open the assets to see how the fields behave.

Priority

One actuator, many callers. When something is already playing, a new call with a worse priority is dropped instead of cutting in.

Haptics.Priority = 0;
Haptics.PlayClip(explosion);      // important, ~0.3 s long

Haptics.Priority = 200;
Haptics.PlayClip(footstep);       // arrives mid-explosion, so it never plays

Whatever does get through takes the actuator: anything still playing is silenced rather than mixed with. Lower numbers win, the range is 0–256, and the default is 128 — the same scale and direction as AudioSource.priority and as Nice Vibrations. Leave it alone everywhere and behaviour is identical to having no priorities at all. Stopping is never gated: StopContinuous and Stop work whatever the caller's priority is.

Global controls

Haptics.HapticsEnabled = false;   // settings toggle; stops anything already playing
Haptics.OutputLevel = 0.5f;       // 0-1 multiplier over every call

if (Haptics.SupportLevel == SupportLevel.None)
    settingsRow.SetActive(false);  // nothing to offer the player, hide the toggle

There is also Haptics.Service, which returns the same instance behind an IHapticService interface if you register things in a container. Both paths mutate the same state.

Platform behaviour

The two platforms are not the same thing wearing different names, and pretending otherwise is how packages end up feeling broken on one of them.

iOS 13+ Android 8+ (API 26) Android below 26 No actuator / Editor
Presets the system's own generators authored approximation on/off approximation silent
Clips, PlayConstant yes yes on/off approximation silent
StartContinuous yes yes fixed-strength buzz silent
UpdateContinuous modulates the running session, no gap restarts playback, ~150 ms steps ignored ignored
Sharpness real frequency axis pulse length, transients only same
OutputLevel between 0 and 1 yes yes mute only
SupportLevel Full Full BasicOnly None

The UpdateContinuous row is the important one. iOS hands you a live player object and sendParameters: retargets it mid-flight. Android has no such object — the only operations are "send a new effect" and "cancel", so every update cancels what is playing and starts again. Updates are coalesced to one send per 150 ms to keep that from turning into a stutter, and the value you let go of always reaches the device even if it arrived inside the window. Tunable through Haptics.ContinuousUpdateInterval; iOS ignores it.

Design notes

No compiled native code. The entire native surface is one .mm file you can read, plus JNI calls made from C#. Nothing is shipped as a binary you have to trust. This costs some capability — I am not writing a DSP engine — and buys the ability to fix a platform bug without waiting for anyone.

Haptics never break the game. Every public method swallows its exceptions. Native failures come back as status codes rather than exceptions, and an engine that dies takes SupportLevel down with it instead of throwing. That claim is enforced by a test driver that throws from every method: each public call has to survive it.

Android continuous playback does not loop. The obvious implementation is a short waveform with a repeat index. AOSP accepts it and reports infinite duration, but on a Galaxy A54 only the first segment ever played — the truncation happens below the framework, where no parameter reaches. So the session is one 30-second non-repeating segment that cancel() cuts short. iOS already used the same trick with a 100-second ceiling. Neither number is a setting; both exist to never be reached.

Sharpness on Android is pulse length, not a fake amplitude. createWaveform takes amplitudes and nothing else. I tried folding sharpness into the amplitude — intensity × (0.85 + 0.15 × sharpness) — and measured it on device: at intensity 0.8 that moves the amplitude from 173 to 204, which nobody can feel. It was dishonest and useless, so I deleted it. What does work is duration: a transient becomes a 40 ms pulse when sharp and a 160 ms one when dull. Continuous sessions still ignore sharpness, because there is no length to vary when the session runs until you stop it.

The preset values are sourced, not invented — and on iOS they are not used at all. They started as plausible-looking numbers with no basis, which is the kind of thing that survives every review because nothing about it looks wrong. Nice Vibrations' preset table gave them a source: three intensity tiers through the whole set — 0.156, 0.471, 1.0 — and, more usefully, the fact that Heavy and Rigid share an intensity and are told apart by duration alone. That is the same distinction this package calls sharpness.

Then two of them turned out to be imperceptible on an iPhone. The table is Nice Vibrations' Android representation; on iOS it calls the system generators and never reads those numbers. We had taken values tuned for a 160 ms amplitude envelope and handed them to an instantaneous transient. So iOS now calls the same system generators, and the table is what Android plays and what the priority window is measured against. Hand-picked constants were the wrong answer twice; the third time the answer was to stop picking them.

Compared to Nice Vibrations

Nice Vibrations is the reference implementation in this space and this package borrows from it openly. The honest summary is that it is more capable and far more battle-tested; RichHaptic is smaller, readable end to end, and covers one thing it doesn't.

RichHaptic Nice Vibrations
Native code source you can read prebuilt LofeltHaptics.framework and .aar, plus two editor binaries
Live modulation, iOS yes yes
Live modulation, Android yes, stepped no — an amplitude change applies on the next Play()
Frequency on Android not offered not offered
Clip format ScriptableObject, edited in the Inspector .haptic JSON from Lofelt Studio
Gamepad rumble, looping, seeking no yes
Authored clip library no yes
License MIT MIT

If you want gamepad support, seekable clips, or a ready-made clip library, use Nice Vibrations. If you want a dependency-free package whose native layer you can read in an afternoon, and live modulation that works on Android instead of quietly doing nothing, this is the trade being offered.

Requirements

Unity 6000.3 or newer. Nothing in the code needs an editor that recent, but that is the version it was built and tested against, and declaring a minimum I have never compiled on would be a guess dressed up as support.

iOS 13+ with haptic hardware — the engine checks CHHapticEngine.capabilitiesForHardware.supportsHaptics rather than trusting the OS version, so an iPad running iOS 17 correctly reports None. Android API 26+ for amplitude control; below that it degrades to BasicOnly instead of failing.

Nothing needs configuring by hand. CoreHaptics.framework is linked by a [PostProcessBuild] step, and the VIBRATE permission arrives through an .androidlib manifest that Gradle merges into yours.

Status

Version 0.1.0, pre-1.0, and the API can still change. 143 EditMode tests cover the service policy layer, the Android waveform maths and state machine, the preset table, and the native struct contracts. What tests cannot reach is the platform code behind #if — that has been verified by hand on an iPhone 15 Pro and a Samsung Galaxy A54. If it misbehaves on your hardware, an issue with the device model and OS version is genuinely useful.

There is no CI badge because there is no CI yet, and the reason is worth stating plainly rather than leaving as a gap. Unity 6 replaced the licensing client with one that verifies entitlement against an online account session instead of trusting a licence file. The personal-licence path the usual CI actions rely on hands that client a .ulf and it never gets as far as reading it — activation fails before the editor starts. Until that path works on a hosted runner, the suite is run locally before anything is tagged. The tests themselves are not the part that is missing.

Inspired by

Nice Vibrations by Lofelt.

Reference documentation: Core Haptics and Android haptics.

License

MIT. See LICENSE.

About

Unity haptics for iOS and Android with live modulation. Talks to Core Haptics and VibrationEffect directly — no native binaries, no dependencies.

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages