Integrating Bluetooth VR Controllers into Mobile VR Apps
Mobile VR with Bluetooth controllers — whether it's the Cardboard/Daydream era or modern solutions like Pico G3 with 3DoF tracking. In both cases, the challenge is the same: receive IMU data (accelerometer, gyroscope, magnetometer) and button presses from the controller over BLE with minimal latency, convert them into position/orientation in 3D space, and feed them into the rendering engine. We take on such projects turnkey: from GATT reverse engineering to integration into Unity or Godot.
How to Reduce BLE Controller Latency?
The standard BLE connection interval is 45 ms, which is critical for VR: the lag is noticeable and causes discomfort. The solution is to switch the controller to high speed via requestConnectionPriority(CONNECTION_PRIORITY_HIGH), reducing the interval to 7.5 ms. Additionally, we use notify characteristics instead of read, eliminating polling. In practice, with proper configuration, the difference between 45 ms and 7.5 ms becomes imperceptible to the user. It is also recommended to disable scanning of other BLE devices during a session to avoid loading the radio frequency channel.
GATT Profile of a BLE Controller
Most VR controllers implement the standard HID over GATT profile or a custom GATT service for IMU data. For custom ones, you need the manufacturer's documentation with UUID characteristics. A typical GATT structure for a VR controller:
-
Service UUID
00001812-0000-1000-8000-00805f9b34fb(HID Service) or custom - Report Characteristic — input data: buttons + IMU (notify)
-
Battery Service
0000180f-0000-1000-8000-00805f9b34fb— battery level (read/notify)
BLE Parameters for VR
| Parameter | Value | Impact on VR |
|---|---|---|
| Connection interval | 7.5 ms (HIGH) | Minimal latency |
| Connection interval | 45 ms (DEFAULT) | Noticeable lag, discomfort |
| Notify | Enabled | Reduced CPU load |
| Read | Not recommended | Increases latency |
class VRControllerGattClient(private val context: Context) {
private var bluetoothGatt: BluetoothGatt? = null
private val CONTROLLER_SERVICE_UUID = UUID.fromString("YOUR-CONTROLLER-UUID")
private val IMU_CHARACTERISTIC_UUID = UUID.fromString("YOUR-IMU-CHAR-UUID")
fun connect(device: BluetoothDevice) {
// TRANSPORT_LE — explicitly specify BLE, not classic BT
bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
gatt.discoverServices()
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
val service = gatt.getService(CONTROLLER_SERVICE_UUID) ?: return
val imuChar = service.getCharacteristic(IMU_CHARACTERISTIC_UUID) ?: return
gatt.setCharacteristicNotification(imuChar, true)
// Enable Client Characteristic Configuration Descriptor
val descriptor = imuChar.getDescriptor(
UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
)
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
parseControllerData(value)
}
}
}
requestConnectionPriority(CONNECTION_PRIORITY_HIGH) — switches the BLE connection interval from default 45ms to 7.5ms. Critical for VR: at 45ms input lag is noticeable as discomfort, at 7.5ms it's imperceptible.
Why Is Sensor Fusion Important?
Raw MEMS gyroscope data drifts, accelerometer data is noisy. Without combining, the orientation drifts within seconds. We use Madgwick or Mahony AHRS algorithms that produce stable quaternions. Madgwick with β=0.1f filters noise, but during fast movements (β=0.3f) accuracy is 2x better than a complementary filter. The β value is a trade-off between reaction speed and noise filtering; for dynamic scenes choose 0.3, for static ones 0.01.
Parsing IMU Data and Sensor Fusion
Data from MEMS gyroscope and accelerometer are raw readings in manufacturer units. Calibration and sensor fusion are needed to obtain a stable quaternion orientation.
data class ControllerState(
val gyroX: Float, val gyroY: Float, val gyroZ: Float, // rad/s
val accelX: Float, val accelY: Float, val accelZ: Float, // m/s²
val buttons: Int, // button bitmask
val trigger: Float, // analog trigger 0..1
val timestamp: Long
)
fun parseControllerData(raw: ByteArray): ControllerState {
val buffer = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN)
return ControllerState(
gyroX = buffer.short.toFloat() / 32768f * GYRO_SCALE, // GYRO_SCALE in rad/s
gyroY = buffer.short.toFloat() / 32768f * GYRO_SCALE,
gyroZ = buffer.short.toFloat() / 32768f * GYRO_SCALE,
accelX = buffer.short.toFloat() / 32768f * ACCEL_SCALE,
accelY = buffer.short.toFloat() / 32768f * ACCEL_SCALE,
accelZ = buffer.short.toFloat() / 32768f * ACCEL_SCALE,
buttons = buffer.short.toInt(),
trigger = (buffer.byte.toInt() and 0xFF) / 255f,
timestamp = SystemClock.elapsedRealtimeNanos()
)
}
For orientation — complementary filter (fast) or Madgwick/Mahony AHRS (more accurate):
| Algorithm | Orientation Accuracy | Performance | Recommendation |
|---|---|---|---|
| Complementary filter | Medium | High (50 µs) | For static scenes |
| Madgwick AHRS | High (2x better) | Medium (200 µs) | For dynamics |
| Mahony AHRS | High | Medium (180 µs) | Alternative |
class MadgwickFilter(private val beta: Float = 0.1f) {
private var q = floatArrayOf(1f, 0f, 0f, 0f) // orientation quaternion
fun update(gx: Float, gy: Float, gz: Float,
ax: Float, ay: Float, az: Float, dt: Float) {
// Madgwick AHRS algorithm
// Normalize accelerometer
val norm = sqrt(ax * ax + ay * ay + az * az)
if (norm == 0f) return
// ... full algorithm implementation
// Result: q[0..3] — current orientation quaternion
}
fun getQuaternion() = Quaternion(q[0], q[1], q[2], q[3])
}
beta = 0.1f is a trade-off between reaction speed and noise filtering. For fast movements increase to 0.3, for static decrease to 0.01. After algorithm processing, it's important to calibrate the gyroscope to compensate for zero offset.
Integration with VR Rendering
On Android — pass controller data to Unity via AndroidJavaClass or directly into OpenXR via an XR_EXT_hand_tracking-compatible plugin. For Godot — a GodotAndroidPlugin with exposed methods. A typical mistake: passing controller data directly from the BLE callback thread to the render thread. A thread-safe buffer is needed:
// Atomic reference for the latest controller state
private val latestState = AtomicReference<ControllerState?>()
override fun onCharacteristicChanged(..., value: ByteArray) {
latestState.set(parseControllerData(value))
}
// From render thread (each frame)
fun pollControllerState(): ControllerState? = latestState.getAndSet(null)
How We Configure BLE for VR: Step by Step
- Obtain GATT documentation from the manufacturer or reverse engineer the protocol.
- Connect the controller with HIGH priority and enable notifications.
- Parse IMU data and apply sensor fusion (Madgwick or Mahony).
- Calibrate sensors and test latency on a real device.
- Integrate into the engine: Unity, Godot, or Unreal.
What's Included in the Work
- GATT profile documentation (UUIDs and data format)
- Connection and parsing code for Android/iOS
- Sensor fusion tuned to the controller
- Integration into Unity/Godot/Unreal
- Sensor calibration and latency testing
- 1-month post-deployment support
- Pricing: Basic integration from $500; reverse engineering from $1500
Our team has over 6 years of experience in Bluetooth integration and has delivered 30+ VR projects. We guarantee low latency and accurate tracking. With a strong track record, we provide reliable solutions. Contact us for a free consultation to assess your project.
Timeline
BLE connection to an existing VR controller with ready GATT documentation, IMU parsing, integration into Unity/Godot: 3–5 days. Development with reverse engineering of an unknown controller's GATT protocol + custom sensor fusion: 1–2 weeks. Get in touch to obtain a turnkey solution. Typical projects start at $500.







