Реалізація 3DoF Head Tracking у мобільних VR-додатках
3DoF (три степені свободи) — це обертання: pitch (кивок), yaw (поворот), roll (нахил голови). Смартфон знає орієнтацію через IMU — акселерометр та гіроскоп. Об'єднати дані двох датчиків у стабільну орієнтацію без накопичення drift — ось де вся складність.
IMU Fusion: чому не можна використовувати лише гіроскоп
Гіроскоп вимірює кутову швидкість з високою точністю та низьким шумом. Інтегруємо за часом — отримуємо кут повороту. Проблема: числове інтегрування накопичує помилку. За кілька хвилин гіроскоп "дрейфує" на кілька градусів — віртуальний горизонт змінюється.
Акселерометр у статиці показує на центр Землі — це абсолютна орієнтація. Проблема: при русі акселерометр не розрізняє гравітацію від прискорення, його дані шумні.
Рішення — Complementary Filter або Madgwick/Mahony фільтр:
// Android: спрощений Complementary Filter
class ComplementaryFilter(val alpha: Float = 0.98f) {
private var pitch = 0f
private var roll = 0f
fun update(gyroDelta: FloatArray, accel: FloatArray, dt: Float) {
// Кут з гіроскопа (швидко, точно короткострокова)
val gyroPitch = pitch + gyroDelta[0] * dt
val gyroRoll = roll + gyroDelta[1] * dt
// Кут з акселерометра (повільно, абсолютна орієнтація)
val accelPitch = Math.toDegrees(Math.atan2(accel[1].toDouble(), accel[2].toDouble())).toFloat()
val accelRoll = Math.toDegrees(Math.atan2(-accel[0].toDouble(), accel[2].toDouble())).toFloat()
// Мішаємо: 98% гіроскопа + 2% акселерометра
pitch = alpha * gyroPitch + (1f - alpha) * accelPitch
roll = alpha * gyroRoll + (1f - alpha) * accelRoll
}
}
alpha = 0.98 — стандартне значення. При швидких рухах голови тимчасово знижуємо alpha (більше довіри акселерометру), при повільних — підвищуємо.
Android SensorManager: читаємо IMU правильно
На Android IMU читається через SensorManager. Два варіанти: TYPE_ROTATION_VECTOR (уже дає fusion з системи) або сирі TYPE_GYROSCOPE + TYPE_ACCELEROMETER + кастомний fusion.
TYPE_GAME_ROTATION_VECTOR — спеціально для ігор: не використовує магнітометр (компас), тому не залежить від металевих об'єктів поруч. Для VR — найкращий вибір:
val sensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
val gameRotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
sensorManager.registerListener(object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
// event.values: [x, y, z, w] quaternion
val rotationMatrix = FloatArray(16)
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
// Застосовуємо до camera transform
updateCameraRotation(rotationMatrix)
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}, gameRotationSensor, SensorManager.SENSOR_DELAY_FASTEST) // ~500Hz
SENSOR_DELAY_FASTEST — критично для VR. SENSOR_DELAY_GAME (~50Hz) дає помітне запізнення при швидких поворотах.
iOS: CoreMotion та CMMotionManager
На iOS все простіше: CMMotionManager.deviceMotion вже містить fusion з акселерометра, гіроскопа та магнітометра. Attitude повертає як CMAttitude з roll, pitch, yaw або quaternion:
let motionManager = CMMotionManager()
motionManager.deviceMotionUpdateInterval = 1.0 / 60.0 // 60Hz мінімум, краще 90+
motionManager.startDeviceMotionUpdates(
using: .xArbitraryZVertical, // не залежить від магнітного північного
to: .main
) { [weak self] motion, error in
guard let motion else { return }
let quaternion = motion.attitude.quaternion
self?.cameraNode.orientation = SCNQuaternion(
x: Float(quaternion.x),
y: Float(quaternion.y),
z: Float(quaternion.z),
w: Float(quaternion.w)
)
}
xArbitraryZVertical — reference frame без залежності від магнітного північного. Початковий напрямок довільний, що правильно для VR: користувач сам обирає куди дивитися при запуску.
Latency: головний враг комфорту
Motion-to-photon latency — час від руху голови до оновлення картинки на екрані. Поріг комфорту: < 20ms. Типовий pipeline:
- IMU → sensor event: 1–3ms
- Sensor event → camera rotation update: 1–5ms (залежить від thread scheduling)
- Camera rotation → render: 8–16ms (один кадр при 60–120 FPS)
- Render → display: 8–16ms (display latency)
Загалом легко виходить 20–40ms. ATW (Asynchronous TimeWarp) у Cardboard SDK бере останній rendered кадр та перепроецирує його з урахуванням нової орієнтації — віртуально знижує motion-to-photon latency без зменшення render time.
Recenter (скидання орієнтації)
Користувач повернувся боком або встав — його "прямо вперед" змінилася. Recenter встановлює поточну орієнтацію голови як нульову:
// iOS
func recenter() {
referenceAttitude = motionManager.deviceMotion?.attitude.copy() as? CMAttitude
}
// У update: застосовуємо delta щодо reference
func updateCamera() {
guard let current = motionManager.deviceMotion?.attitude,
let reference = referenceAttitude else { return }
current.multiply(byInverseOf: reference)
// використовуємо current.quaternion як camera rotation
}
Recenter зазвичай прив'язаний до кнопки Cardboard або до спеціального жесту (струс пристрою).
Робочий процес
Вибір IMU API: системний rotation vector проти сирих гіроскопа/акселерометра з кастомним fusion.
Реалізація читання датчиків з мінімальною latency, на виділеному потоці.
Синхронізація орієнтації з рендером, налаштування recenter.
Тестування drift: 10-хвилинна сесія без recenter, оцінка накопленої помилки.
Інтеграція з ATW через Cardboard SDK.
Оцінка часу
Базовий 3DoF head tracking через системний rotation vector — 1–2 дні. Кастомна реалізація з власним fusion, latency оптимізацією, recenter — 3–5 днів.







