Реализация 3DoF-трекинга головы в мобильном VR-приложении
3DoF (three degrees of freedom) — это вращение: pitch (кивок), yaw (поворот), roll (наклон головы). Смартфон знает, куда направлен, через IMU — акселерометр и гироскоп. Объединить данные двух сенсоров в стабильную ориентацию без дрейфа — вот где кроется вся сложность.
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 vs сырые гироскоп/акселерометр с кастомным fusion.
Реализация чтения сенсоров с минимальной latency, на выделенном потоке.
Синхронизация ориентации с рендером, настройка recenter.
Тестирование drift: 10-минутная сессия без recenter, оценка накопленной ошибки.
Интеграция с ATW через Cardboard SDK.
Ориентиры по срокам
Базовый 3DoF head tracking через системный rotation vector — 1–2 дня. Кастомная реализация с собственным fusion, latency оптимизацией и recenter — 3–5 дней.







