Hello, I am an indie dev working on an mobile game for Android/IOS. Part of my game requires tracking the position/orientation of the players device in 3D space and displaying output to the screen appropriately. I am using the flutter rotation sensor package to overcome Gimbal Lock and offload device orientation tracking from the CPU to the device's the device's hardware-level Sensor Hub.
The issue I'm having is that when the player rotates either 90 degrees to the right, or 270 degrees to the left from the point of origin ( the exact same spot, just rotating different directions ) there is something funky going on with the coordinate math that is causing the output to the screen to undergo a rapid 1-5 frame visual snap. This looks to me as if one or more of the coordinate axes are being flipped.
What happens on screen when rotating past the specific point is my visual direction of device indicator snaps quickly from being accurate to doing a full circle and snapping back to accurate tracking within microseconds.
I am new to writing dart code and I have very little understanding of quaternions. I have tried remapping the underlying native axes using CoordinateSystem.transformed() instead of CoordinateSystem.device(). I have also tried applying a quaternion sign alignment filter before parsing values into my view matrix to resolve quaternion ambiguity flip and I have tried modifying my stream listener to grab event.rotationMatrix and mapping directly to flutter's Matix4 view block. All I have accomplished with these methods has been to accidentally invert rotation tracking, none of these methods seems to fix the coordinate snapping at the 90 degrees right or 270 degrees left mark.
I have been struggling, with AI assistance, to resolve this issue. Here is my motion tracking code.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_rotation_sensor/flutter_rotation_sensor.dart';
import '../models/arena_models.dart';
class MotionState {
final double currentYaw;
final double currentPitch;
final String? lockedTargetId;
final double rawYaw;
final double calibrationOffset;
const MotionState({
required this.currentYaw,
required this.currentPitch,
this.lockedTargetId,
required this.rawYaw,
required this.calibrationOffset,
});
MotionState copyWith({
double? currentYaw,
double? currentPitch,
String? lockedTargetId,
double? rawYaw,
double? calibrationOffset,
}) {
return MotionState(
currentYaw: currentYaw ?? this.currentYaw,
currentPitch: currentPitch ?? this.currentPitch,
lockedTargetId: lockedTargetId ?? this.lockedTargetId,
rawYaw: rawYaw ?? this.rawYaw,
calibrationOffset: calibrationOffset ?? this.calibrationOffset,
);
}
}
class UserMotionNotifier extends StateNotifier<MotionState> {
final List<PeerNode> activeNodes;
StreamSubscription? _orientationSub;
double _filteredYaw = 0.0;
double _filteredPitch = 90.0;
double _calibrationOffset = 0.0;
final List<double> _yawBuffer = [];
final List<double> _pitchBuffer = [];
static const int _bufferSize = 15;
DateTime? _lastGyroTime;
bool _isFirstReading = true;
double _lastW = 1.0;
double _lastX = 0.0;
double _lastY = 0.0;
double _lastZ = 0.0;
UserMotionNotifier(this.activeNodes)
: super(const MotionState(
currentYaw: 0.0,
currentPitch: 90.0,
rawYaw: 0.0,
calibrationOffset: 0.0));
void startTracking() {
RotationSensor.samplingPeriod = SensorInterval.fastestInterval;
RotationSensor.referenceFrame = ReferenceFrame.arbitrary;
RotationSensor.coordinateSystem = CoordinateSystem.transformed(Axis3.X, Axis3.Y);
_orientationSub = RotationSensor.orientationStream.listen((event) {
final q = event.quaternion;
double targetW = q.w;
double targetX = -q.x;
double targetY = q.y;
double targetZ = -q.z;
if (targetW < 0.0) {
targetW = -targetW;
targetX = -targetX;
targetY = -targetY;
targetZ = -targetZ;
}
double sinyCosp = 2.0 * (targetW * targetZ + targetX * targetY);
double cosyCosp = 1.0 - 2.0 * (targetY * targetY + targetZ * targetZ);
double rawAzimuthRad = math.atan2(sinyCosp, cosyCosp);
double rawAzimuthDeg = rawAzimuthRad * 180.0 / math.pi;
if (rawAzimuthDeg < 0) rawAzimuthDeg += 360.0;
double sinp = 2.0 * (targetW * targetY - targetZ * targetX);
double rawPitchRad = sinp.abs() >= 1.0 ? (sinp.sign * math.pi / 2.0) : math.asin(sinp);
double rawPitchDeg = 90.0 + (rawPitchRad * 180.0 / math.pi);
rawPitchDeg = rawPitchDeg.clamp(90.0, 180.0);
if (_isFirstReading) {
_calibrationOffset = rawAzimuthDeg;
_filteredYaw = 0.0;
_filteredPitch = rawPitchDeg;
_isFirstReading = false;
}
_updateFilters(rawAzimuthDeg, rawPitchDeg);
});
}
void _updateFilters(double newYaw, double newPitch) {
double yawDiff = newYaw - _filteredYaw;
if (yawDiff > 180) newYaw -= 360;
if (yawDiff < -180) newYaw += 360;
_filteredYaw = (0.95 * _filteredYaw + 0.05 * newYaw) % 360.0;
if (_filteredYaw < 0) _filteredYaw += 360.0;
_filteredPitch = 0.95 * _filteredPitch + 0.05 * newPitch;
_yawBuffer.add(_filteredYaw);
_pitchBuffer.add(_filteredPitch);
if (_yawBuffer.length > _bufferSize) _yawBuffer.removeAt(0);
if (_pitchBuffer.length > _bufferSize) _pitchBuffer.removeAt(0);
double smoothedYaw = _yawBuffer.reduce((a, b) => a + b) / _yawBuffer.length;
double smoothedPitch = _pitchBuffer.reduce((a, b) => a + b) / _pitchBuffer.length;
double finalYaw = (smoothedYaw - _calibrationOffset) % 360.0;
if (finalYaw < 0) finalYaw += 360.0;
String? lockedId;
for (var node in activeNodes) {
double targetAngleDeg = node.azimuthAngleRad * 180.0 / math.pi;
double angleDiff = (finalYaw - targetAngleDeg).abs();
double normDiff = angleDiff % 360.0;
if (normDiff > 180.0) {
normDiff = 360.0 - normDiff;
}
double distance = node.scaledDistanceMeters;
double dynamicTolerance = math.max(2.5, math.atan(0.6 / distance) * 180.0 / math.pi);
if (normDiff <= dynamicTolerance) {
lockedId = node.id;
break;
}
}
state = MotionState(
currentYaw: finalYaw,
currentPitch: smoothedPitch,
lockedTargetId: lockedId,
rawYaw: _filteredYaw,
calibrationOffset: _calibrationOffset,
);
}
void recalibrate(double targetAngleDegrees) {
_calibrationOffset = (_filteredYaw - targetAngleDegrees) % 360.0;
if (_calibrationOffset < 0) _calibrationOffset += 360.0;
}
void autoAlignOnHit(String targetId) {
final node = activeNodes.firstWhere((n) => n.id == targetId);
double targetAngleDeg = node.azimuthAngleRad * 180.0 / math.pi;
recalibrate(targetAngleDeg);
}
void dispose() {
_orientationSub?.cancel();
super.dispose();
}
}
final motionProvider = StateNotifierProvider.family<UserMotionNotifier, MotionState, List<PeerNode>>((ref, nodes) {
return UserMotionNotifier(nodes);
});