O feedback tátil é um pilar crítico na construção de experiências imersivas em realidade virtual — especialmente em dispositivos como os headsets Pico, onde a interação com controladores é contínua e física. Ao contrário de efeitos visuais ou sonoros, as vibrações operam no nível subconsciente, reforçando a credibilidade do mundo virtual por meio da coerência física percebida nas mãos do usuário. Um toque mal calibrado pode quebrar a ilusão; um padrão bem projetado torna-se parte integrante da narrativa interativa.
Este artigo explora cinco abordagens práticas para implementar vibrações sofisticadas usando o SDK oficial da Pico para Unity, com foco em PXR_Input.SetControllerVibration. Cada padrão foi concebido para atender a necessidades específicas de contexto — desde respostas sutis em interfaces até impulsos dinâmicos sincronizados com eventos físicos complexos. Os exemplos de código são escritos em C# e incluem estratégias de gerenciamento de desempenho, controle de sobreposição e adaptação perceptual.
1. Fundamentos do Sistema Tátil da Pico
A API central para ativação de vibração é simples em assinatura, mas rica em implicações comportamentais:
using Unity.XR.PXR;
PXR_Input.SetControllerVibration(float amplitude, int durationMs, Controller side);
- Amplitude (0.0–1.0): Representa intensidade relativa percebida, não potência elétrica bruta. Devido à sansibilidade não linear da pele humana, variações abaixo de 0.2 têm impacto proporcionalmente maior em sensações delicadas — como vento leve ou toque de folhas.
- Duração (ms): Ideal para pulsos curtos (< 100 ms). Vibrações prolongadas devem ser construídas via sequências controladas, evitando sobreaquecimento do motor e perda de clareza temporal.
- Lado do controlador: Permite diferenciação espacial. Por exemplo, ao receber um impacto à esquerda, aplicar 0.75 de amplitude no controlador esquerdo e 0.15 no direito reforça a direção do estímulo.
Além disso, a natureza dos motores LRA (Linear Resonant Actuators) nos controladores Pico favorece padrões de pulsação fina. Embora a frequência não seja exposta diretamente, modulações rápidas — como alternância entre 0.4/0.1 em intervalos de 25 ms — simulam texturas ou vibrações contínuas com alta fidelidade perceptual.
2. Cinco Padrões de Vibração com Código Funcional
2.1 Pulso Contextualizado com Níveis Pré-definidos
Substitui chamadas genéricas por uma enumeração semântica, facilitando manutenção e consistência entre designers e programadores.
public class TactileEmitter : MonoBehaviour
{
public enum IntensityLevel { Feather, Tap, Thud, Impact }
private static readonly Dictionary<IntensityLevel, (float amp, int ms)> Presets =
new()
{
[IntensityLevel.Feather] = (0.06f, 12),
[IntensityLevel.Tap] = (0.22f, 28),
[IntensityLevel.Thud] = (0.44f, 42),
[IntensityLevel.Impact] = (0.78f, 65)
};
public void Emit(IntensityLevel level, Controller target = Controller.Left)
{
var (amp, dur) = Presets[level];
PXR_Input.SetControllerVibration(amp, dur, target);
}
}
Essa estrutura permite mapear eventos de jogo diretamente — por exemplo, Emit(IntensityLevel.Thud) ao soltar um objeto pesado, ou Emit(IntensityLevel.Feather) ao passar o dedo sobre uma superfície lisa.
2.2 Vibração Modulada por Velocidade Angular
Útil para simular resistência em objetos rotativos (como válvulas, rodas ou mecanismos de engrenagem), onde a vibração aumenta conforme a velocidade de rotação do objeto interagido.
public class RotationalHaptic : MonoBehaviour
{
[SerializeField] private float baseAmplitude = 0.15f;
[SerializeField] private float maxAmplitude = 0.6f;
[SerializeField] private float rpmThreshold = 60f;
private Rigidbody rb;
private float lastRpm;
void Start() => rb = GetComponent<Rigidbody>();
void Update()
{
float currentRpm = rb.angularVelocity.magnitude * Mathf.Rad2Deg / 60f;
if (Mathf.Abs(currentRpm - lastRpm) > 2f)
{
float amp = Mathf.Lerp(baseAmplitude, maxAmplitude,
Mathf.Clamp01((currentRpm - 10f) / (rpmThreshold - 10f)));
PXR_Input.SetControllerVibration(amp, 10, Controller.Right);
lastRpm = currentRpm;
}
}
}
2.3 Sequência Rítmica com Sincronização de Áudio
Para eventos repetitivos como disparos automáticos ou batidas de coração, essa classe gera uma sequência de vibrações alinhada com o tempo de um áudio em loop, garantindo coerência multisensorial.
public class RhythmicHapticPlayer : MonoBehaviour
{
[SerializeField] private AudioClip heartbeatClip;
[SerializeField] private float hapticScale = 0.4f;
private AudioSource audioSource;
private float nextBeatTime;
void Start()
{
audioSource = gameObject.AddComponent<AudioSource>();
audioSource.clip = heartbeatClip;
audioSource.playOnAwake = false;
audioSource.loop = true;
audioSource.volume = 0f;
audioSource.Play();
nextBeatTime = Time.time + heartbeatClip.length;
}
void Update()
{
if (Time.time >= nextBeatTime)
{
float amp = Mathf.Lerp(0.1f, 0.5f, audioSource.volume);
PXR_Input.SetControllerVibration(amp * hapticScale, 20, Controller.Both);
nextBeatTime += heartbeatClip.length;
}
}
}
2.4 Vibração Adaptativa por Material Físico
Integra dados do PhysicsMaterial de um objeto colidido para ajustar automaticamente a resposta tátil — por exemplo, madeira produz um "estalo" curto, metal gera um "zumbido" mais longo e denso.
public class MaterialBasedHaptic : MonoBehaviour
{
[System.Serializable]
public struct MaterialResponse
{
public PhysicMaterial material;
public float amplitude;
public int duration;
public float frequencyMod; // usado para variação de pulsação
}
[SerializeField] private MaterialResponse[] responses;
void OnCollisionEnter(Collision collision)
{
var mat = collision.otherCollider.sharedMaterial;
var match = System.Array.Find(responses, r => r.material == mat);
if (match.material != null)
{
int adjustedDuration = (int)(match.duration * (1f + Random.Range(-0.15f, 0.15f)));
float jitteredAmp = match.amplitude * (0.9f + Random.Range(0f, 0.2f));
PXR_Input.SetControllerVibration(jitteredAmp, adjustedDuration, Controller.Both);
}
}
}
2.5 Gerenciador Centralizado com Priorização
Evita conflitos quando múltiplas fontes tentam acionar vibrações simultaneamente. Usa uma fila com prioridade e supresssão de sobreposição para manter a clareza tátil.
public class HapticScheduler : MonoBehaviour
{
private static HapticScheduler instance;
private readonly Queue<(float amp, int dur, Controller side, int priority)> queue =
new PriorityQueue<(float, int, Controller, int), int>();
void Awake()
{
if (instance == null) { instance = this; DontDestroyOnLoad(gameObject); }
else Destroy(gameObject);
}
public static void Enqueue(float amp, int duration, Controller side, int priority = 0)
{
instance.queue.Enqueue((amp, duration, side, priority), priority);
}
void Update()
{
if (queue.Count > 0 && queue.TryPeek(out var next, out _))
{
PXR_Input.SetControllerVibration(next.amp, next.dur, next.side);
queue.Dequeue();
}
}
}
Uso: HapticScheduler.Enqueue(0.5f, 40, Controller.Left, priority: 10) para um evento crítico como detonação de granada — superando notificações secundárias de UI.