Implementing VR Controllers via Bluetooth in Mobile App
Mobile VR with Bluetooth controllers — Cardboard/Daydream era or modern solutions like Pico G3 with 3DoF tracking. In both cases, the task is same: get IMU data (accelerometer, gyroscope, magnetometer) and buttons from controller via BLE with minimum latency, convert to 3D position/orientation, and pass to engine rendering.
BLE Controller GATT Profile
Most VR controllers implement standard HID over GATT profile or custom GATT service for IMU data. For custom — need manufacturer documentation with characteristic UUIDs.
Typical VR controller GATT structure:
-
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— charge level (read/notify)
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 BLE connection interval from default 45ms to 7.5ms. Critical for VR: at 45ms input lag is noticeable discomfort, at 7.5ms imperceptible.
Parsing IMU Data and Sensor Fusion
Data from MEMS gyro and accelerometer — raw readings in manufacturer units. Needs calibration and sensor fusion for 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 precise):
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 compromise between response speed and noise filtering. On quick movement increase to 0.3, on static decrease to 0.01.
VR Rendering Integration
On Android — pass controller data to Unity via AndroidJavaClass or directly to OpenXR via XR_EXT_hand_tracking-compatible plugin. For Godot — GodotAndroidPlugin with exposed methods.
Common mistake: pass controller data directly from BLE callback thread to render thread. Need thread-safe buffer:
// Atomic reference for latest controller state
private val latestState = AtomicReference<ControllerState?>()
override fun onCharacteristicChanged(..., value: ByteArray) {
latestState.set(parseControllerData(value))
}
// From render thread (every frame)
fun pollControllerState(): ControllerState? = latestState.getAndSet(null)
Timeline
BLE connection to existing VR controller with ready GATT documentation, IMU parsing, Unity/Godot integration: 3–5 days. Development with GATT protocol reverse-engineering of unknown controller + custom sensor fusion: 1–2 weeks.







