This commit is contained in:
wheaney 2025-01-14 16:11:39 -08:00
parent f4e081cd73
commit ebc3910c9d
4 changed files with 663 additions and 267 deletions

View File

@ -0,0 +1,174 @@
import Gio from 'gi://Gio';
import GObject from 'gi://GObject';
import Globals from './globals.js';
import {
dataViewEnd,
dataViewUint8,
dataViewBigUint,
dataViewUint32Array,
dataViewUint8Array,
dataViewFloat,
dataViewFloatArray,
BOOL_SIZE,
FLOAT_SIZE,
UINT_SIZE,
UINT8_SIZE
} from "./ipc.js";
import { isValidKeepAlive, getEpochSec, toSec } from "./time.js";
const IPC_FILE_PATH = "/dev/shm/breezy_desktop_imu";
const KEEPALIVE_REFRESH_INTERVAL_SEC = 1;
// the driver should be using the same data layout version
const DATA_LAYOUT_VERSION = 3;
// DataView info: [offset, size, count]
const VERSION = [0, UINT8_SIZE, 1];
const ENABLED = [dataViewEnd(VERSION), BOOL_SIZE, 1];
const LOOK_AHEAD_CFG = [dataViewEnd(ENABLED), FLOAT_SIZE, 4];
const DISPLAY_RES = [dataViewEnd(LOOK_AHEAD_CFG), UINT_SIZE, 2];
const DISPLAY_FOV = [dataViewEnd(DISPLAY_RES), FLOAT_SIZE, 1];
const LENS_DISTANCE_RATIO = [dataViewEnd(DISPLAY_FOV), FLOAT_SIZE, 1];
const SBS_ENABLED = [dataViewEnd(LENS_DISTANCE_RATIO), BOOL_SIZE, 1];
const CUSTOM_BANNER_ENABLED = [dataViewEnd(SBS_ENABLED), BOOL_SIZE, 1];
const EPOCH_MS = [dataViewEnd(CUSTOM_BANNER_ENABLED), UINT_SIZE, 2];
const IMU_QUAT_DATA = [dataViewEnd(EPOCH_MS), FLOAT_SIZE, 16];
const IMU_PARITY_BYTE = [dataViewEnd(IMU_QUAT_DATA), UINT8_SIZE, 1];
const DATA_VIEW_LENGTH = dataViewEnd(IMU_PARITY_BYTE);
function checkParityByte(dataView) {
const parityByte = dataViewUint8(dataView, IMU_PARITY_BYTE);
let parity = 0;
const epochUint8 = dataViewUint8Array(dataView, EPOCH_MS);
const imuDataUint8 = dataViewUint8Array(dataView, IMU_QUAT_DATA);
for (let i = 0; i < epochUint8.length; i++) {
parity ^= epochUint8[i];
}
for (let i = 0; i < imuDataUint8.length; i++) {
parity ^= imuDataUint8[i];
}
return parityByte === parity;
}
export const DeviceDataStream = GObject.registerClass({
Properties: {
'supported-device-connected': GObject.ParamSpec.boolean(
'supported-device-connected',
'Supported device connected',
'Whether a supported device is connected',
GObject.ParamFlags.READWRITE,
false
),
'widescreen-mode-state': GObject.ParamSpec.boolean(
'widescreen-mode-state',
'Widescreen mode state',
'The state of widescreen mode from the perspective of the driver',
GObject.ParamFlags.READWRITE,
false
),
'quaternion': GObject.ParamSpec.jsobject(
'quaternion',
'Quaternion',
'Camera orientation quaternion',
GObject.ParamFlags.READWRITE
),
}
}, class DeviceDataStream extends GObject.Object {
constructor(params = {}) {
super(params);
this.supported_device_connected = false;
this._ipc_file = Gio.file_new_for_path(IPC_FILE_PATH);
this._running = false;
this._device_data = null;
}
start() {
this._running = true;
this._poll();
}
stop() {
this._running = false;
}
// polling is just intended to keep supported_device_connected current, anything needing up-to-date imu data should
// trigger a refresh with the default flag
_poll() {
if (this._running) {
this.refresh_data(true);
setTimeout(this._poll.bind(this), 1000);
}
}
// Refresh the data from the IPC file. if keepalive_only is true, we'll only check and update supported_device_connected if it
// hasn't been checked within KEEPALIVE_REFRESH_INTERVAL_SEC.
refresh_data(keepalive_only = false) {
if (!this._device_data?.imuData || !keepalive_only || getEpochSec() - this._device_data.imuDateMs > KEEPALIVE_REFRESH_INTERVAL_SEC) {
let data = this._ipc_file.load_contents(null);
if (data[0]) {
let buffer = new Uint8Array(data[1]).buffer;
let dataView = new DataView(buffer);
if (dataView.byteLength === DATA_VIEW_LENGTH) {
const imuDateMs = dataViewBigUint(dataView, EPOCH_MS);
const validKeepalive = isValidKeepAlive(toSec(imuDateMs));
const imuData = dataViewFloatArray(dataView, IMU_QUAT_DATA);
const imuResetState = validKeepalive && imuData[0] === 0.0 && imuData[1] === 0.0 && imuData[2] === 0.0 && imuData[3] === 1.0;
const version = dataViewUint8(dataView, VERSION);
const enabled = dataViewUint8(dataView, ENABLED) !== 0 && version === DATA_LAYOUT_VERSION && validKeepalive;
const sbsEnabled = dataViewUint8(dataView, SBS_ENABLED) !== 0;
// update the widescreen property if the state changes while still enabled, trigger "notify::" events
if (enabled && this.widescreen_mode_state !== sbsEnabled) this.widescreen_mode_state = sbsEnabled;
if (!this._device_data) {
this._device_data = {
version,
enabled,
imuResetState,
displayRes: dataViewUint32Array(dataView, DISPLAY_RES),
sbsEnabled,
displayFov: dataViewFloat(dataView, DISPLAY_FOV),
lookAheadCfg: dataViewFloatArray(dataView, LOOK_AHEAD_CFG),
};
}
let success = keepalive_only;
let attempts = 0;
while (!success && attempts < 3) {
if (dataView.byteLength === DATA_VIEW_LENGTH) {
if (checkParityByte(dataView)) {
this._device_data.imuData = imuData;
this._device_data.imuDateMs = imuDateMs;
this.quaternion = {
x: imuData[0],
y: imuData[1],
z: imuData[2],
w: imuData[3]
};
success = true;
}
} else if (dataView.byteLength !== 0) {
Globals.logger.log(`[ERROR] Invalid dataView.byteLength: ${dataView.byteLength} !== ${DATA_VIEW_LENGTH}`)
}
if (!success && ++attempts < 3) {
data = this._ipc_file.load_contents(null);
if (data[0]) {
buffer = new Uint8Array(data[1]).buffer;
dataView = new DataView(buffer);
}
}
}
if (success) {
// update the supported device connected property if the state changes, trigger "notify::" events
if (this.supported_device_connected !== validKeepalive) this.supported_device_connected = validKeepalive;
}
}
} else {
this.supported_device_connected = false;
}
}
}
});

View File

@ -1,11 +1,13 @@
import Clutter from 'gi://Clutter'
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Meta from 'gi://Meta';
import Shell from 'gi://Shell';
import St from 'gi://St';
import { CursorManager } from './cursormanager.js';
import { DeviceDataStream } from './devicedatastream.js';
import Globals from './globals.js';
import { Logger } from './logger.js';
import { MonitorManager } from './monitormanager.js';
@ -38,8 +40,9 @@ export default class BreezyDesktopExtension extends Extension {
// Set/destroyed by enable/disable
this._cursor_manager = null;
this._device_data_stream = null;
this._monitor_manager = null;
this._xr_effect = null;
this.overlay_content = null;
this._overlay = null;
this._target_monitor = null;
this._is_effect_running = false;
@ -76,6 +79,9 @@ export default class BreezyDesktopExtension extends Extension {
Globals.extension_dir = this.path;
this.settings.bind('debug', Globals.logger, 'debug', Gio.SettingsBindFlags.DEFAULT);
this._device_data_stream = new DeviceDataStream();
this._device_data_stream.start();
this._monitor_manager = new MonitorManager({
use_optimal_monitor_config: this.settings.get_boolean('use-optimal-monitor-config'),
headset_as_primary: this.settings.get_boolean('headset-as-primary'),
@ -107,7 +113,7 @@ export default class BreezyDesktopExtension extends Extension {
return GLib.SOURCE_REMOVE;
}
if (this._check_driver_running() && target_monitor) {
if (this._device_data_stream.supported_device_connected && target_monitor) {
// Don't enable the effect yet if monitor updates are needed.
// _setup will be triggered again since a !ready result means it will trigger monitor changes,
// so we can remove this timeout_add no matter what.
@ -118,6 +124,7 @@ export default class BreezyDesktopExtension extends Extension {
this._running_poller_id = undefined;
return GLib.SOURCE_REMOVE;
} else {
Globals.logger.log_debug(`BreezyDesktopExtension _poll_for_ready - device connected: ${this._device_data_stream.supported_device_connected}, target_monitor: ${!!target_monitor}`);
return GLib.SOURCE_CONTINUE;
}
} catch (e) {
@ -186,7 +193,7 @@ export default class BreezyDesktopExtension extends Extension {
if (target_monitor && this._running_poller_id === undefined) {
this._target_monitor = target_monitor;
if (this._check_driver_running()) {
if (this._device_data_stream.supported_device_connected) {
// Don't enable the effect yet if monitor updates are needed.
// _setup will be triggered again since a !ready result means it will trigger monitor changes
if (this._target_monitor_ready(target_monitor)) {
@ -200,27 +207,10 @@ export default class BreezyDesktopExtension extends Extension {
this._poll_for_ready();
}
} else {
Globals.logger.log_debug(`BreezyDesktopExtension _setup - Doing nothing, driver running: ${this._check_driver_running()}, target_monitor found: ${!!target_monitor}`);
Globals.logger.log_debug(`BreezyDesktopExtension _setup - Doing nothing, device connected: ${this._device_data_stream.supported_device_connected}, target_monitor found: ${!!target_monitor}`);
}
}
_check_driver_running() {
try {
if (!Globals.ipc_file) Globals.ipc_file = Gio.file_new_for_path(IPC_FILE_PATH);
if (Globals.ipc_file.query_exists(null)) {
const file_info = Globals.ipc_file.query_info(Gio.FILE_ATTRIBUTE_TIME_MODIFIED, Gio.FileQueryInfoFlags.NONE, null);
const file_modified_time = file_info.get_attribute_uint64(Gio.FILE_ATTRIBUTE_TIME_MODIFIED);
// when the driver is running, the IMU file is updated at least 60x per second, do a strict check
return isValidKeepAlive(file_modified_time, true);
}
} catch (e) {
Globals.logger.log(`[ERROR] BreezyDesktopExtension _check_driver_running ${e.message}\n${e.stack}`);
}
return false;
}
_needs_widescreen_monitor_update() {
Globals.logger.log_debug('BreezyDesktopExtension _needs_widescreen_monitor_update');
const state = this._read_state();
@ -251,22 +241,21 @@ export default class BreezyDesktopExtension extends Extension {
this._overlay.opacity = 255;
this._overlay.set_position(targetMonitor.x, targetMonitor.y);
this._overlay.set_size(targetMonitor.width, targetMonitor.height);
// this._overlay.inhibit_culling();
// const textureSourceActor = Main.layoutManager.uiGroup;
const overlayContent = new TestActor({
this.overlay_content = new TestActor({
monitors: [],
quaternion: {
x: 0.094, y: 0.079, z: 0.094, w: 0.988
},
fov_degrees: 46.0,
width: 100,
height: 100,
'z-position': 0
// width: 100,
// height: 100,
width: targetMonitor.width,
height: targetMonitor.height,
display_distance: this.settings.get_double('display-distance'),
toggle_display_distance_start: this.settings.get_double('toggle-display-distance-start'),
toggle_display_distance_end: this.settings.get_double('toggle-display-distance-end')
});
// overlayContent.inhibit_culling();
this._overlay.set_child(overlayContent);
this._overlay.set_child(this.overlay_content);
Shell.util_set_hidden_from_pick(this._overlay, true);
global.stage.add_child(this._overlay);
@ -281,30 +270,6 @@ export default class BreezyDesktopExtension extends Extension {
clutterContainer ? 'actor-removed' : 'child-removed',
this._handle_sibling_update.bind(this),
);
// this._xr_effect = new XREffect({
// target_monitor: targetMonitor,
// target_framerate: refreshRate,
// texture_monitor_position: {
// // x: targetMonitor.x - textureSourceActor.x,
// // y: targetMonitor.y - textureSourceActor.y
// x: 0,
// y: 0
// },
// display_distance: this.settings.get_double('display-distance'),
// toggle_display_distance_start: this.settings.get_double('toggle-display-distance-start'),
// toggle_display_distance_end: this.settings.get_double('toggle-display-distance-end'),
// look_ahead_override: this.settings.get_int('look-ahead-override'),
// disable_anti_aliasing: this.settings.get_boolean('disable-anti-aliasing')
// });
// this._xr_effect = new TestActorEffect({
// quaternion: {
// x: 0.094, y: 0.079, z: 0.094, w: 0.988
// },
// fov_degrees: 46.0,
// width: targetMonitor.width,
// height: targetMonitor.height
// });
this._update_follow_threshold(this.settings);
@ -314,24 +279,35 @@ export default class BreezyDesktopExtension extends Extension {
// this._widescreen_mode_effect_state_connection = this._xr_effect.connect('notify::widescreen-mode-state', this._update_widescreen_mode_from_state.bind(this));
// this._supported_device_detected_connection = this._xr_effect.connect('notify::supported-device-detected', this._handle_supported_device_change.bind(this));
this.overlay_content.renderMonitors();
this._data_stream_connection = this._device_data_stream.bind_property(
'quaternion',
this.overlay_content,
'quaternion',
GObject.BindingFlags.DEFAULT
);
// this._distance_binding = this.settings.bind('display-distance', this._xr_effect, 'display-distance', Gio.SettingsBindFlags.DEFAULT)
this._distance_connection = this.settings.connect('changed::display-distance', this._update_display_distance.bind(this))
this._follow_threshold_connection = this.settings.connect('changed::follow-threshold', this._update_follow_threshold.bind(this))
this._distance_binding = this.settings.bind('display-distance', this.overlay_content, 'display-distance', Gio.SettingsBindFlags.DEFAULT);
this._distance_connection = this.settings.connect('changed::display-distance', this._update_display_distance.bind(this));
this._follow_threshold_connection = this.settings.connect('changed::follow-threshold', this._update_follow_threshold.bind(this));
// this._widescreen_mode_settings_connection = this.settings.connect('changed::widescreen-mode', this._update_widescreen_mode_from_settings.bind(this))
// this._start_binding = this.settings.bind('toggle-display-distance-start', this._xr_effect, 'toggle-display-distance-start', Gio.SettingsBindFlags.DEFAULT)
// this._end_binding = this.settings.bind('toggle-display-distance-end', this._xr_effect, 'toggle-display-distance-end', Gio.SettingsBindFlags.DEFAULT)
this._start_binding = this.settings.bind('toggle-display-distance-start', this.overlay_content, 'toggle-display-distance-start', Gio.SettingsBindFlags.DEFAULT)
this._end_binding = this.settings.bind('toggle-display-distance-end', this.overlay_content, 'toggle-display-distance-end', Gio.SettingsBindFlags.DEFAULT)
// this._curved_display_binding = this.settings.bind('curved-display', this._xr_effect, 'curved-display', Gio.SettingsBindFlags.DEFAULT)
// this._display_size_binding = this.settings.bind('display-size', this._xr_effect, 'display-size', Gio.SettingsBindFlags.DEFAULT);
// this._look_ahead_override_binding = this.settings.bind('look-ahead-override', this._xr_effect, 'look-ahead-override', Gio.SettingsBindFlags.DEFAULT);
// this._disable_anti_aliasing_binding = this.settings.bind('disable-anti-aliasing', this._xr_effect, 'disable-anti-aliasing', Gio.SettingsBindFlags.DEFAULT);
// this._ui_clone.add_effect_with_name('xr-desktop', this._xr_effect);
Meta.disable_unredirect_for_display(global.display);
global.stage.connect('before-paint', (() => {
this._device_data_stream.refresh_data();
this._overlay.queue_redraw();
}).bind(this));
this._add_settings_keybinding('recenter-display-shortcut', this._recenter_display.bind(this));
// this._add_settings_keybinding('toggle-display-distance-shortcut', this._xr_effect._change_distance.bind(this._xr_effect));
this._add_settings_keybinding('toggle-display-distance-shortcut', this.overlay_content._change_distance.bind(this.overlay_content));
this._add_settings_keybinding('toggle-follow-shortcut', this._toggle_follow_mode.bind(this));
} catch (e) {
Globals.logger.log(`[ERROR] BreezyDesktopExtension _effect_enable ${e.message}\n${e.stack}`);
@ -462,12 +438,12 @@ export default class BreezyDesktopExtension extends Extension {
}
_update_widescreen_mode_from_settings(settings, event) {
const value = settings.get_boolean('widescreen-mode');
Globals.logger.log_debug(`BreezyDesktopExtension _update_widescreen_mode_from_settings ${value}`);
if (value !== undefined && value !== this._xr_effect.widescreen_mode_state) {
this._request_sbs_mode_change(value);
} else
Globals.logger.log_debug('effect.widescreen_mode_state already matched setting');
// const value = settings.get_boolean('widescreen-mode');
// Globals.logger.log_debug(`BreezyDesktopExtension _update_widescreen_mode_from_settings ${value}`);
// if (value !== undefined && value !== this._xr_effect.widescreen_mode_state) {
// this._request_sbs_mode_change(value);
// } else
// Globals.logger.log_debug('effect.widescreen_mode_state already matched setting');
}
_update_widescreen_mode_from_state(effect, _pspec) {
@ -538,9 +514,7 @@ export default class BreezyDesktopExtension extends Extension {
this._actor_removed_connection = null;
}
if (this._overlay) {
// if (this._xr_effect) this._xr_effect.cleanup();
if (this._ui_clone) this._ui_clone.remove_effect_by_name('xr-desktop');
this._ui_clone = null;
this.overlay_content = null;
global.stage.remove_child(this._overlay);
this._overlay.destroy();
@ -554,6 +528,10 @@ export default class BreezyDesktopExtension extends Extension {
this.settings.disconnect(this._distance_connection);
this._distance_connection = null;
}
if (this._data_stream_connection) {
this._device_data_stream.unbind(this._data_stream_connection);
this._data_stream_connection = null;
}
if (this._follow_threshold_connection) {
this.settings.disconnect(this._follow_threshold_connection);
this._follow_threshold_connection = null;
@ -586,17 +564,17 @@ export default class BreezyDesktopExtension extends Extension {
this.settings.unbind(this._disable_anti_aliasing_binding);
this._disable_anti_aliasing_binding = null;
}
// if (this._xr_effect) {
// if (this._widescreen_mode_effect_state_connection) {
// this._xr_effect.disconnect(this._widescreen_mode_effect_state_connection);
// this._widescreen_mode_effect_state_connection = null;
// }
// if (this._supported_device_detected_connection) {
// this._xr_effect.disconnect(this._supported_device_detected_connection);
// this._supported_device_detected_connection = null;
// }
// this._xr_effect = null;
// }
if (this.overlay_content) {
// if (this._widescreen_mode_effect_state_connection) {
// this._xr_effect.disconnect(this._widescreen_mode_effect_state_connection);
// this._widescreen_mode_effect_state_connection = null;
// }
// if (this._supported_device_detected_connection) {
// this._xr_effect.disconnect(this._supported_device_detected_connection);
// this._supported_device_detected_connection = null;
// }
this.overlay_content = null;
}
if (this._cursor_manager) {
this._cursor_manager.disable();
this._cursor_manager = null;
@ -618,6 +596,12 @@ export default class BreezyDesktopExtension extends Extension {
Globals.logger.log_debug('BreezyDesktopExtension disable');
this._effect_disable();
this._target_monitor = null;
if (this._device_data_stream) {
this._device_data_stream.stop();
this._device_data_stream = null;
}
if (this._monitor_manager) {
if (this._optimal_monitor_config_binding) {
this.settings.unbind(this._optimal_monitor_config_binding);

View File

@ -35,7 +35,7 @@ function getDisplayConfigProxy(extPath) {
xml = new TextDecoder().decode(bytes);
}
} catch (e) {
Globals.logger.log('ERROR: failed to load DisplayConfig interface XML');
Globals.logger.log('[ERROR] failed to load DisplayConfig interface XML');
throw e;
}
cachedDisplayConfigProxy = Gio.DBusProxy.makeProxyWrapper(xml);

View File

@ -1,14 +1,193 @@
import Clutter from 'gi://Clutter'
import Cogl from 'gi://Cogl';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Shell from 'gi://Shell';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import Globals from './globals.js';
function applyQuaternionToVector(vector, quaternion) {
const t = [
2.0 * (quaternion[1] * vector[2] - quaternion[2] * vector[1]),
2.0 * (quaternion[2] * vector[0] - quaternion[0] * vector[2]),
2.0 * (quaternion[0] * vector[1] - quaternion[1] * vector[0])
];
return [
vector[0] + quaternion[3] * t[0] + quaternion[1] * t[2] - quaternion[2] * t[1],
vector[1] + quaternion[3] * t[1] + quaternion[2] * t[0] - quaternion[0] * t[2],
vector[2] + quaternion[3] * t[2] + quaternion[0] * t[1] - quaternion[1] * t[0]
];
}
/**
* Find the vector in the array that's closest to the quaternion rotation
*
* @param {number[]} quaternion - Reference quaternion [w, x, y, z]
* @param {number[][]} vectors - Array of vectors [x, y, z] to search from
* @returns {number} Index of the closest vector, if it surpasses the previous closest index by a certain margin, otherwise the previous index
*/
function findClosestVector(quaternion, vectors, previousClosestIndex) {
const lookVector = [1.0, 0.0, 0.0]; // NWU vector pointing to the center of the screen
const rotatedLookVector = applyQuaternionToVector(lookVector, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]);
Globals.logger.log(`\t\t\tQuaternion: ${JSON.stringify(quaternion)}`);
Globals.logger.log(`\t\t\tRotated look vector: ${rotatedLookVector}`);
let closestIndex = -1;
let closestDistance = Infinity;
let previousDistance = Infinity;
// find the vector closest to the rotated look vector
vectors.forEach((vector, index) => {
const distance = Math.acos(
Math.min(1.0, Math.max(-1.0, vector[0] * rotatedLookVector[0] + vector[1] * rotatedLookVector[1] + vector[2] * rotatedLookVector[2]))
);
if (previousClosestIndex === index) {
previousDistance = distance;
}
Globals.logger.log(`\t\t\tMonitor ${index} distance: ${distance}`);
if (distance < closestDistance) {
closestIndex = index;
closestDistance = distance;
}
});
Globals.logger.log(`\t\t\tClosest monitor: ${closestIndex}, distance: ${closestDistance}`);
// only switch if the closest monitor is greater than the previous closest by 25%
if (previousClosestIndex !== undefined && closestIndex !== previousClosestIndex && closestDistance * 1.25 > previousDistance) {
return previousClosestIndex;
}
return closestIndex;
}
function degreesToRadians(degrees) {
return degrees * Math.PI / 180.0;
}
function radiansToDegrees(radians) {
return radians * 180.0 / Math.PI;
}
/***
* @returns {Object} - containing `center` and `end` radians
*/
function monitorWrap(radiusPixels, previousMonitorEndRadians, monitorPixels) {
const monitorHalfPixels = monitorPixels / 2;
const monitorHalfRadians = Math.asin(monitorHalfPixels / radiusPixels);
const centerRadians = previousMonitorEndRadians + monitorHalfRadians;
return {
center: centerRadians,
end: centerRadians + monitorHalfRadians
}
}
/**
* Convert the given monitor details into NWU vectors pointing to the center of each monitor.
*
* @param {Object} fovDetails - contains reference fovDegrees (diagonal), widthPixels, heightPixels
* @param {Object[]} monitorDetailsList - contains x, y, width, height (coordinates from top-left)
* @param {string} monitorWrappingScheme - horizontal, vertical, none
* @returns {number[]} - Vector [x, y, z]
*/
function monitorsToVectors(fovDetails, monitorDetailsList, monitorWrappingScheme) {
const aspect = fovDetails.widthPixels / fovDetails.heightPixels;
const fovVerticalRadians = degreesToRadians(fovDetails.fovDegrees / Math.sqrt(1 + aspect * aspect));
// NWU vectors pointing to the center of the screen for each monitor
const monitorVectors = [];
if (monitorWrappingScheme === 'horizontal') {
// monitors wrap around us horizontally
const fovHorizontalRadians = fovVerticalRadians * aspect;
// radius is the hypothenuse of the triangle where the opposite side is half the width of the reference fov screen
const radius = fovDetails.widthPixels / 2 / Math.sin(fovHorizontalRadians / 2);
let previousMonitorEndRadians = -fovHorizontalRadians / 2;
monitorDetailsList.forEach(monitorDetails => {
const monitorWrapDetails = monitorWrap(radius, previousMonitorEndRadians, monitorDetails.width);
previousMonitorEndRadians = monitorWrapDetails.end;
monitorVectors.push([
// north is adjacent where radius is the hypotenuse, using monitorWrapDetails.center as the radians
radius * Math.cos(monitorWrapDetails.center),
// west is opposite where radius is the hypotenuse, using monitorWrapDetails.center as the radians
-radius * Math.sin(monitorWrapDetails.center),
// up is flat when wrapping horizontally
-(monitorDetails.y + monitorDetails.height / 2 - fovDetails.heightPixels / 2)
]);
});
} else if (monitorWrappingScheme === 'vertical') {
// monitors wrap around us vertically
// radius is the hypothenuse of the triangle where the opposite side is half the height of the reference fov screen
const radius = fovDetails.heightPixels / 2 / Math.sin(fovVerticalRadians / 2);
let previousMonitorEndRadians = -fovVerticalRadians / 2;
monitorDetailsList.forEach(monitorDetails => {
const monitorWrapDetails = monitorWrap(radius, previousMonitorEndRadians, monitorDetails.height);
previousMonitorEndRadians = monitorWrapDetails.end;
monitorVectors.push([
// north is adjacent where radius is the hypotenuse, using monitorWrapDetails.center as the radians
radius * Math.cos(monitorWrapDetails.center),
// west is flat when wrapping vertically
-(monitorDetails.x + monitorDetails.width / 2 - fovDetails.widthPixels / 2),
// up is opposite where radius is the hypotenuse, using monitorWrapDetails.center as the radians
-radius * Math.sin(monitorWrapDetails.center)
]);
});
} else {
// monitors make a flat wall in front of us, no wrapping
monitorDetailsList.forEach(monitorDetails => {
monitorVectors.push([
fovDetails.heightPixels / 2 / Math.sin(fovVerticalRadians / 2),
-(monitorDetails.x + monitorDetails.width / 2 - fovDetails.widthPixels / 2),
-(monitorDetails.y + monitorDetails.height / 2 - fovDetails.heightPixels / 2)
]);
});
}
return monitorVectors;
}
function monitorVectorToRotationAngle(vector, monitorWrappingScheme) {
if (monitorWrappingScheme === 'horizontal') {
// monitors wrap around us horizontally
return {
angle: radiansToDegrees(Math.atan2(vector[1], vector[0])),
axis: Clutter.RotateAxis.Y_AXIS
};
} else if (monitorWrappingScheme === 'vertical') {
// monitors wrap around us vertically
return {
angle: radiansToDegrees(Math.atan2(vector[2], vector[0])),
axis: Clutter.RotateAxis.X_AXIS
}
} else {
// no rotation
return undefined;
}
}
export const TestActorEffect = GObject.registerClass({
Properties: {
'monitor-index': GObject.ParamSpec.int(
'monitor-index',
'Monitor Index',
'Index of the monitor that this effect is applied to',
GObject.ParamFlags.READWRITE,
0, 100, 0
),
'quaternion': GObject.ParamSpec.jsobject(
'quaternion',
'Quaternion',
@ -35,140 +214,126 @@ export const TestActorEffect = GObject.registerClass({
'Height of the viewport',
GObject.ParamFlags.READWRITE,
1, 10000, 1080
)
),
'monitor-wrapping-scheme': GObject.ParamSpec.string(
'monitor-wrapping-scheme',
'Monitor Wrapping Scheme',
'How the monitors are wrapped around the viewport',
GObject.ParamFlags.READWRITE,
'horizontal', ['horizontal', 'vertical', 'none']
),
'focused-monitor-index': GObject.ParamSpec.int(
'focused-monitor-index',
'Focused Monitor Index',
'Index of the monitor that is currently focused',
GObject.ParamFlags.READWRITE,
0, 100, 0
),
'display-distance': GObject.ParamSpec.double(
'display-distance',
'Display Distance',
'Distance of the display from the camera',
GObject.ParamFlags.READWRITE,
0.2,
2.5,
1.0
),
'toggle-display-distance-start': GObject.ParamSpec.double(
'toggle-display-distance-start',
'Display distance start',
'Start distance when using the "change distance" shortcut.',
GObject.ParamFlags.READWRITE,
0.2,
2.5,
1.05
),
'toggle-display-distance-end': GObject.ParamSpec.double(
'toggle-display-distance-end',
'Display distance end',
'End distance when using the "change distance" shortcut.',
GObject.ParamFlags.READWRITE,
0.2,
2.5,
1.05
),
}
}, class TestActorEffect extends Shell.GLSLEffect {
constructor(params = {}) {
super(params);
perspective(fovDiagonalRadians, aspect, near, far) {
// compute horizontal fov given diagonal fov and aspect ratio
const h = Math.sqrt(aspect * aspect + 1);
const fovRadians = fovDiagonalRadians / h * aspect;
console.log(`fovRadians: ${fovRadians}`);
// Compute the projection matrix
let aspectRatio = this.width / this.height;
let fovRadians = this.fov_degrees * (Math.PI / 180);
let near = 0.1;
let far = 1000.0;
let projectionMatrix = this._computeProjectionMatrix(fovRadians, aspectRatio, near, far);
Globals.logger.log(JSON.stringify(projectionMatrix));
// Compute the view matrix from the quaternion
let viewMatrix = this._computeViewMatrixFromQuaternion(this.quaternion);
Globals.logger.log(JSON.stringify(viewMatrix));
let rotationMatrix = this._createRotationMatrix(this.quaternion);
Globals.logger.log(JSON.stringify(rotationMatrix));
}
_computeProjectionMatrix(fovRadians, aspect, near, far) {
let f = 1.0 / Math.tan(fovRadians / 2);
let nf = 1 / (near - far);
let projectionMatrix = [
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * nf, -1,
0, 0, (2 * far * near) * nf, 0
];
return projectionMatrix;
}
_computeViewMatrixFromQuaternion(q) {
let x = q.x, y = q.y, z = q.z, w = q.w;
let x2 = x + x;
let y2 = y + y;
let z2 = z + z;
let xx = x * x2;
let xy = x * y2;
let xz = x * z2;
let yy = y * y2;
let yz = y * z2;
let zz = z * z2;
let wx = w * x2;
let wy = w * y2;
let wz = w * z2;
let viewMatrix = [
1 - (yy + zz), xy - wz, xz + wy, 0,
xy + wz, 1 - (xx + zz), yz - wx, 0,
xz - wy, yz + wx, 1 - (xx + yy), 0,
0, 0, 0, 1
];
// Invert the view matrix (since it's from camera space)
// For rotation matrices, the inverse is the transpose
let inverseViewMatrix = [
viewMatrix[0], viewMatrix[4], viewMatrix[8], 0,
viewMatrix[1], viewMatrix[5], viewMatrix[9], 0,
viewMatrix[2], viewMatrix[6], viewMatrix[10], 0,
0, 0, 0, 1
];
return viewMatrix;
}
_createRotationMatrix(q) {
// Normalize the quaternion
const len = Math.sqrt(
q.x * q.x +
q.y * q.y +
q.z * q.z +
q.w * q.w
);
const x = q.x / len;
const y = q.y / len;
const z = q.z / len;
const w = q.w / len;
// Compute matrix elements
const x2 = x * x;
const y2 = y * y;
const z2 = z * z;
const xy = x * y;
const xz = x * z;
const yz = y * z;
const wx = w * x;
const wy = w * y;
const wz = w * z;
// Create rotation matrix
const f = 1.0 / Math.tan(fovRadians / 2.0);
const range = far - near;
return [
1.0 - 2.0 * (y2 + z2), // m00
2.0 * (xy - wz), // m01
2.0 * (xz + wy), // m02
0.0, // m03
2.0 * (xy + wz), // m10
1.0 - 2.0 * (x2 + z2), // m11
2.0 * (yz - wx), // m12
0.0, // m13
2.0 * (xz - wy), // m20
2.0 * (yz + wx), // m21
1.0 - 2.0 * (x2 + y2), // m22
0.0, // m23
0.0, // m30
0.0, // m31
0.0, // m32
1.0 // m33
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, - (far + near) / range, -1,
0, 0, - (2.0 * near * far) / range, 0
];
}
vfunc_build_pipeline() {
const declarations = `
uniform mat4 u_rotation_matrix;
uniform mat4 u_view_matrix;
uniform vec4 u_quaternion;
uniform mat4 u_projection_matrix;
uniform float u_display_north_offset;
vec4 applyQuaternionToVector(vec4 v, vec4 q) {
vec3 t = 2.0 * cross(q.xyz, v.xyz);
vec3 rotated = v.xyz + q.w * t + cross(q.xyz, t);
return vec4(rotated, v.w);
}
`;
const main = `
vec4 world_pos = cogl_position_in;
world_pos = u_rotation_matrix * world_pos;
// // move pixel space to texcoord space
// world_pos.x = (world_pos.x / 192.0);
// world_pos.y = (world_pos.y / 108.0);
// float displayAspectRatio = 1920.0 / 1080.0;
// float diagToVertRatio = sqrt(pow(displayAspectRatio, 2) + 1);
// float halfFovZRads = radians(46.0 / diagToVertRatio) / 2.0;
// float halfFovYRads = halfFovZRads * displayAspectRatio;
// vec2 fovHalfWidths = vec2(tan(halfFovYRads), tan(halfFovZRads));
// vec2 fovWidths = fovHalfWidths * 2.0;
// float vec_y = -world_pos.x * fovWidths.x + fovHalfWidths.x;
// float vec_z = -world_pos.y * fovWidths.y + fovHalfWidths.y;
// vec4 look_vector = vec4(1.0, vec_y, vec_z, 1.0);
// // vec3 rotated_vector = applyQuaternionToVector(look_vector, u_quaternion).xyz;
// vec3 rotated_vector = look_vector.xyz;
// // scale back to the screen distance
// rotated_vector /= rotated_vector.x;
// cogl_position_out = vec4(
// ((fovHalfWidths.x - rotated_vector.y) / fovWidths.x) * 2.0 - 1.0,
// ((fovHalfWidths.y - rotated_vector.z) / fovWidths.y) * 2.0 - 1.0,
// 0.0,
// 1.0
// );
// float z_orig = world_pos.z;
// world_pos.z -= z_orig / 1920.0;
// world_pos.x /= 2.0;
// world_pos *= u_display_north_offset;
world_pos = applyQuaternionToVector(world_pos, u_quaternion);
// world_pos /= u_display_north_offset;
// world_pos.x *= 2.0;
// world_pos.z += z_orig / 1920.0;
world_pos = cogl_modelview_matrix * world_pos;
cogl_position_out = cogl_projection_matrix * world_pos;
// cogl_position_out.x = world_pos.x / 103.4;
// cogl_position_out.y = world_pos.y / 29.075;
// cogl_position_out.z = -1.0;
// cogl_position_out.w = 1.0;
cogl_tex_coord_out[0] = cogl_tex_coord_in;
`
@ -176,21 +341,23 @@ export const TestActorEffect = GObject.registerClass({
}
vfunc_paint_target(node, paintContext) {
// Compute the projection matrix
let aspectRatio = this.width / this.height;
let fovRadians = this.fov_degrees * (Math.PI / 180);
let near = 0.1;
let far = 1000.0;
if (!this._initialized) {
const aspect = this.get_actor().width / this.get_actor().height;
const projection_matrix = this.perspective(
this.fov_degrees * Math.PI / 180.0,
aspect,
0.0001,
1000.0
);
Globals.logger.log(`aspect: ${aspect}, fov: ${this.fov_degrees}, width: ${this.get_actor().width}, height: ${this.get_actor().height}, projection matrix: ${JSON.stringify(projection_matrix)}`);
this.set_uniform_matrix(this.get_uniform_location("u_projection_matrix"), false, 4, projection_matrix);
this._initialized = true;
}
let projectionMatrix = this._computeProjectionMatrix(fovRadians, aspectRatio, near, far);
this.set_uniform_float(this.get_uniform_location("u_display_north_offset"), 1, [this.focused_monitor_index === this.monitor_index ? this.display_distance : this.toggle_display_distance_start]);
// Compute the view matrix from the quaternion
let viewMatrix = this._computeViewMatrixFromQuaternion(this.quaternion);
// Set up the uniforms
this.set_uniform_matrix(this.get_uniform_location("u_projection_matrix"), false, 4, projectionMatrix);
this.set_uniform_matrix(this.get_uniform_location("u_view_matrix"), false, 4, viewMatrix);
this.set_uniform_matrix(this.get_uniform_location("u_rotation_matrix"), false, 4, this._createRotationMatrix(this.quaternion));
// NUW to east-up-south conversion, inverted
this.set_uniform_float(this.get_uniform_location("u_quaternion"), 4, [this.quaternion.y, -this.quaternion.z, this.quaternion.x, this.quaternion.w]);
this.get_pipeline().set_layer_filters(
0,
@ -223,87 +390,158 @@ export const TestActor = GObject.registerClass({
GObject.ParamFlags.READWRITE,
30.0, 100.0, 46.0
),
'width': GObject.ParamSpec.int(
'width',
'Width',
'Width of the viewport',
'focused-monitor-index': GObject.ParamSpec.int(
'focused-monitor-index',
'Focused Monitor Index',
'Index of the monitor that is currently focused',
GObject.ParamFlags.READWRITE,
1, 10000, 1920
0, 100, 0
),
'height': GObject.ParamSpec.int(
'height',
'Height',
'Height of the viewport',
'display-size': GObject.ParamSpec.double(
'display-size',
'Display size',
'Size of the display',
GObject.ParamFlags.READWRITE,
1, 10000, 1080
)
0.2,
2.5,
1.0
),
'display-distance': GObject.ParamSpec.double(
'display-distance',
'Display Distance',
'Distance of the display from the camera',
GObject.ParamFlags.READWRITE,
0.2,
2.5,
1.0
),
'toggle-display-distance-start': GObject.ParamSpec.double(
'toggle-display-distance-start',
'Display distance start',
'Start distance when using the "change distance" shortcut.',
GObject.ParamFlags.READWRITE,
0.2,
2.5,
1.05
),
'toggle-display-distance-end': GObject.ParamSpec.double(
'toggle-display-distance-end',
'Display distance end',
'End distance when using the "change distance" shortcut.',
GObject.ParamFlags.READWRITE,
0.2,
2.5,
1.05
),
}
}, class TestActor extends Clutter.Actor {
constructor(params = {}) {
super({...params});
// Set the size of the viewport (implicitly provides aspect ratio)
// You can set the size when adding this actor to the stage
// this.set_size(this.width, this.height);
// Create the monitor actors
this._createMonitorActors();
// Apply the shader effect to this viewport actor
// this._applyShaderEffect();
}
_createMonitorActors() {
Main.layoutManager.monitors.forEach((monitor, index) => {
renderMonitors() {
this.monitorsAsVectors = monitorsToVectors(
{
fovDegrees: this.fov_degrees,
widthPixels: this.width,
heightPixels: this.height
},
Main.layoutManager.monitors.map(monitor => ({
x: monitor.x,
y: monitor.y,
width: monitor.width,
height: monitor.height
})),
'horizontal'
);
this.monitorAsNormalizedVectors = this.monitorsAsVectors.map(vector => {
const length = Math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]);
return [vector[0] / length, vector[1] / length, vector[2] / length];
});
Main.layoutManager.monitors.forEach(((monitor, index) => {
// if (index === 0) return;
Globals.logger.log(`\t\t\tMonitor ${index}: ${monitor.x}, ${monitor.y}, ${monitor.width}, ${monitor.height}`);
// this is in NWU coordinates
const monitorVector = this.monitorsAsVectors[index];
const monitorRotation = monitorVectorToRotationAngle(monitorVector, 'horizontal');
Globals.logger.log_debug(`\t\t\tMonitor ${index} vector: ${monitorVector} rotation: ${JSON.stringify(monitorRotation)}`);
// actor coordinates are east-up-south
const containerActor = new Clutter.Actor({
x: -monitor.x,
y: monitor.y,
'z-position': -500,
x: -monitorVector[1],
y: -monitorVector[2],
'z-position': -monitorVector[0],
width: monitor.width,
height: monitor.height,
reactive: false
});
// Create a clone of the stage content for this monitor
const monitorClone = new Clutter.Clone({
source: Main.layoutManager.uiGroup,
reactive: false
});
monitorClone.x = -monitor.x;
monitorClone.x = -containerActor.x;
// monitorActor.y = 0;
// Set the size and position of the clone to match the monitor
// monitorActor.set_size(monitor.width, monitor.height);
// // Apply clipping to show only this monitor's area
monitorClone.set_clip(monitor.x, 0, monitor.width, monitor.height);
// Position the monitor actor within the 3D scene
// monitorActor.set_position(0, 0);
// // For 3D positioning, we might want to center the monitors around (0,0,0)
// // Adjust positions accordingly
// monitorActor.set_translation(monitor.x, monitor.y, 1.0);
// Add the monitor actor to the scene
containerActor.add_child(monitorClone);
containerActor.add_effect_with_name('viewport-effect', new TestActorEffect({
containerActor.set_pivot_point(0.5, 0.5);
containerActor.set_rotation_angle(monitorRotation.axis, monitorRotation.angle);
const effect = new TestActorEffect({
quaternion: this.quaternion,
fov_degrees: this.fov_degrees,
width: this.width,
height: this.height
}));
monitor_index: index,
display_distance: this.toggle_display_distance_start
});
containerActor.add_effect_with_name('viewport-effect', effect);
this.add_child(containerActor);
});
this.bind_property('quaternion', effect, 'quaternion', GObject.BindingFlags.DEFAULT);
this.bind_property('focused-monitor-index', effect, 'focused-monitor-index', GObject.BindingFlags.DEFAULT);
this.bind_property('display-distance', effect, 'display-distance', GObject.BindingFlags.DEFAULT);
}).bind(this));
GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, (() => {
if (this.quaternion) {
const closestMonitorIndex = findClosestVector(this.quaternion, this.monitorAsNormalizedVectors, this.closestMonitorIndex);
// only switch if the closest monitor is greater than the previous closest by 25%
if (this.closestMonitorIndex === undefined || this.closestMonitorIndex !== closestMonitorIndex) {
Globals.logger.log(`Switching to monitor ${closestMonitorIndex}`);
this.closestMonitorIndex = closestMonitorIndex;
}
}
return GLib.SOURCE_CONTINUE;
}).bind(this));
this._distance_ease_timeline = null;
this.connect('notify::toggle-display-distance-start', this._handle_display_distance_properties_change.bind(this));
this.connect('notify::toggle-display-distance-end', this._handle_display_distance_properties_change.bind(this));
this.connect('notify::display-distance', this._handle_display_distance_properties_change.bind(this));
this._handle_display_distance_properties_change();
}
_handle_display_distance_properties_change() {
const distance_from_end = Math.abs(this.display_distance - this.toggle_display_distance_end);
const distance_from_start = Math.abs(this.display_distance - this.toggle_display_distance_start);
this._is_display_distance_at_end = distance_from_end < distance_from_start;
}
// _applyShaderEffect() {
// const glslEffect =
_change_distance() {
if (this._distance_ease_timeline?.is_playing()) this._distance_ease_timeline.stop();
// // Apply the shader effect to this viewport actor
// this.add_effect_with_name('viewport-effect', glslEffect);
// }
this._distance_ease_start = this.display_distance;
this._distance_ease_timeline = Clutter.Timeline.new_for_actor(this, 250);
const toggle_display_distance_target = this._is_display_distance_at_end ?
this.toggle_display_distance_start : this.toggle_display_distance_end;
this._distance_ease_timeline.connect('new-frame', () => {
this.display_distance = this._distance_ease_start +
this._distance_ease_timeline.get_progress() *
(toggle_display_distance_target - this._distance_ease_start);
});
this._distance_ease_timeline.start();
}
});