From 20bd0b643521b2543b2e57accd1262e8163de97a Mon Sep 17 00:00:00 2001 From: Devin Bernosky Date: Sat, 27 Jun 2026 15:03:00 -0700 Subject: [PATCH] Add Horizon Lock: track only horizontal (yaw) head movement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Horizon lock" toggle that flattens the head pose to yaw-only, ignoring pitch (vertical) and roll (tilt). The display then only pans horizontally as you turn your head, staying level and at a fixed height. This is a comfort/nausea aid — particularly on glasses such as the Viture Pro, where pulse and micro-movement jitter on the pitch/roll axes makes the display visibly wobble (see #80, #114). Unlike smooth follow, which chases those noisy axes, horizon lock removes them from the rendered orientation entirely. Implementation: a swing-twist decomposition about the NWU up-axis (Z), applied to the pose quaternions in DeviceDataStream, so the shader, look-ahead and follow logic all inherit the yaw-only orientation. Exposed as a GtkSwitch in the GNOME settings (Features group) and wired through the shared gschema. --- gnome/src/devicedatastream.js | 38 +++++++++++++++++++ gnome/src/extension.js | 2 + .../com.xronlinux.BreezyDesktop.gschema.xml | 9 +++++ ui/src/connecteddevice.py | 3 ++ ui/src/gtk/connected-device.ui | 12 ++++++ 5 files changed, 64 insertions(+) diff --git a/gnome/src/devicedatastream.js b/gnome/src/devicedatastream.js index 4d88ff7..0ced9d1 100644 --- a/gnome/src/devicedatastream.js +++ b/gnome/src/devicedatastream.js @@ -40,6 +40,32 @@ const POSE_ORIENTATION = [dataViewEnd(EPOCH_MS), FLOAT_SIZE, 16]; const IMU_PARITY_BYTE = [dataViewEnd(POSE_ORIENTATION), UINT8_SIZE, 1]; const DATA_VIEW_LENGTH = dataViewEnd(IMU_PARITY_BYTE); +// Flatten a packed pose-orientation buffer (mat4 as 16 floats: orientation +// quaternions at floats [0..3] and [4..7], timestamps at [12..15]) to yaw-only. +// Uses a swing-twist decomposition about the NWU up-axis (Z): keep the Z and W +// components, zero X and Y, then renormalize. This removes all pitch and roll +// from the rendered orientation, so the display tracks only horizontal (yaw) +// head movement. The identity quaternion [0,0,0,1] is preserved. +function applyHorizonLock(poseOrientation) { + for (const offset of [0, 4]) { + const z = poseOrientation[offset + 2]; + const w = poseOrientation[offset + 3]; + const norm = Math.hypot(z, w); + poseOrientation[offset] = 0; + poseOrientation[offset + 1] = 0; + if (norm < 1e-6) { + // Yaw is undefined here (a pure 180-degree pitch/roll pose); fall + // back to identity rather than emitting a zero (non-unit) quaternion. + poseOrientation[offset + 2] = 0; + poseOrientation[offset + 3] = 1; + } else { + poseOrientation[offset + 2] = z / norm; + poseOrientation[offset + 3] = w / norm; + } + } + return poseOrientation; +} + function checkParityByte(dataView) { const parityByte = dataViewUint8(dataView, IMU_PARITY_BYTE); let parity = 0; @@ -132,6 +158,13 @@ export const DeviceDataStream = GObject.registerClass({ 'Debug mode that allows for testing with moving IMU values without a device connected', GObject.ParamFlags.READWRITE, false + ), + 'horizon-lock': GObject.ParamSpec.boolean( + 'horizon-lock', + 'Horizon lock', + 'Flatten the head pose to yaw-only (ignore pitch and roll) so the display only tracks horizontal head movement', + GObject.ParamFlags.READWRITE, + false ) } }, class DeviceDataStream extends GObject.Object { @@ -212,9 +245,13 @@ export const DeviceDataStream = GObject.registerClass({ const version = dataViewUint8(dataView, VERSION); const enabled = dataViewUint8(dataView, ENABLED) !== 0 && version === DATA_LAYOUT_VERSION && validData; let poseOrientation = dataViewFloatArray(dataView, POSE_ORIENTATION); + if (this.horizon_lock) applyHorizonLock(poseOrientation); let posePosition = dataViewFloatArray(dataView, POSE_POSITION); let smoothFollowEnabled = !this.legacy_follow_mode && dataViewUint8(dataView, SMOOTH_FOLLOW_ENABLED) !== 0; let smoothFollowOrigin = dataViewFloatArray(dataView, SMOOTH_FOLLOW_ORIGIN_DATA); + // The shader renders from the smooth-follow origin when follow is + // active, so horizon lock must flatten it too (same packed layout). + if (this.horizon_lock) applyHorizonLock(smoothFollowOrigin); const imuResetState = enabled && validData && poseOrientation[0] === 0.0 && poseOrientation[1] === 0.0 && poseOrientation[2] === 0.0 && poseOrientation[3] === 1.0; const customBannerEnabled = dataViewUint8(dataView, CUSTOM_BANNER_ENABLED) !== 0; const sbsEnabled = dataViewUint8(dataView, SBS_ENABLED) !== 0; @@ -281,6 +318,7 @@ export const DeviceDataStream = GObject.registerClass({ dataView = new DataView(buffer); imuDateMs = dataViewBigUint(dataView, EPOCH_MS); poseOrientation = dataViewFloatArray(dataView, POSE_ORIENTATION); + if (this.horizon_lock) applyHorizonLock(poseOrientation); posePosition = dataViewFloatArray(dataView, POSE_POSITION); } } diff --git a/gnome/src/extension.js b/gnome/src/extension.js index 52f41f8..21f786c 100644 --- a/gnome/src/extension.js +++ b/gnome/src/extension.js @@ -88,6 +88,7 @@ export default class BreezyDesktopExtension extends Extension { this.settings.bind('disable-physical-displays', this._monitor_manager, 'disable-physical-displays', Gio.SettingsBindFlags.DEFAULT); this.settings.bind('legacy-follow-mode', Globals.data_stream, 'legacy-follow-mode', Gio.SettingsBindFlags.DEFAULT); this.settings.bind('debug-no-device', Globals.data_stream, 'debug-no-device', Gio.SettingsBindFlags.DEFAULT); + this.settings.bind('horizon-lock', Globals.data_stream, 'horizon-lock', Gio.SettingsBindFlags.DEFAULT); this._breezy_desktop_running_connection = Globals.data_stream.connect('notify::breezy-desktop-running', this._handle_breezy_desktop_running_change.bind(this)); @@ -732,6 +733,7 @@ export default class BreezyDesktopExtension extends Extension { Gio.Settings.unbind(this.settings, 'headset-as-primary'); Gio.Settings.unbind(this.settings, 'disable-physical-displays'); Gio.Settings.unbind(this.settings, 'debug-no-device'); + Gio.Settings.unbind(this.settings, 'horizon-lock'); if (this._monitor_manager) { this._monitor_manager.disable(); diff --git a/ui/data/com.xronlinux.BreezyDesktop.gschema.xml b/ui/data/com.xronlinux.BreezyDesktop.gschema.xml index 7a9fc9a..c9327a6 100644 --- a/ui/data/com.xronlinux.BreezyDesktop.gschema.xml +++ b/ui/data/com.xronlinux.BreezyDesktop.gschema.xml @@ -154,6 +154,15 @@ Enable curved display mode + + + false + + Horizon lock + + Lock the display to the horizon, tracking only horizontal (yaw) head movement and ignoring pitch (vertical) and roll (tilt) + + -1 diff --git a/ui/src/connecteddevice.py b/ui/src/connecteddevice.py index f1bf08d..e14be72 100644 --- a/ui/src/connecteddevice.py +++ b/ui/src/connecteddevice.py @@ -39,6 +39,7 @@ class ConnectedDevice(Gtk.Box): follow_threshold_adjustment = Gtk.Template.Child() follow_mode_switch = Gtk.Template.Child() curved_display_switch = Gtk.Template.Child() + horizon_lock_switch = Gtk.Template.Child() top_features_group = Gtk.Template.Child() virtual_displays_row = Gtk.Template.Child() add_virtual_display_menu = Gtk.Template.Child() @@ -99,6 +100,7 @@ class ConnectedDevice(Gtk.Box): self.follow_mode_switch, self.follow_threshold_scale, self.curved_display_switch, + self.horizon_lock_switch, self.add_virtual_display_menu, self.add_virtual_display_button, self.change_all_displays_distance_button, @@ -124,6 +126,7 @@ class ConnectedDevice(Gtk.Box): self.settings.bind('follow-threshold', self.follow_threshold_adjustment, 'value', Gio.SettingsBindFlags.DEFAULT) # self.settings.bind('widescreen-mode', self.widescreen_mode_switch, 'active', Gio.SettingsBindFlags.DEFAULT) self.settings.bind('curved-display', self.curved_display_switch, 'active', Gio.SettingsBindFlags.DEFAULT) + self.settings.bind('horizon-lock', self.horizon_lock_switch, 'active', Gio.SettingsBindFlags.DEFAULT) self.settings.bind('headset-display-as-viewport-center', self.headset_display_as_viewport_center_switch, 'active', Gio.SettingsBindFlags.DEFAULT) self.settings.bind('headset-as-primary', self.headset_as_primary_switch, 'active', Gio.SettingsBindFlags.DEFAULT) self.settings.bind('remove-virtual-displays-on-disable', self.remove_virtual_displays_on_disable_switch, 'active', Gio.SettingsBindFlags.DEFAULT) diff --git a/ui/src/gtk/connected-device.ui b/ui/src/gtk/connected-device.ui index 89fd925..d1c5425 100644 --- a/ui/src/gtk/connected-device.ui +++ b/ui/src/gtk/connected-device.ui @@ -88,6 +88,18 @@ + + + Horizon lock + Track only horizontal head movement; ignore looking up/down and head tilt. + 2 + + + 3 + + + + Disable physical displays