mirror of
https://github.com/miyu0-bit/frameeye-helper.git
synced 2026-10-06 03:00:14 +02:00
first version
This commit is contained in:
commit
3d43bfe5ca
47 files changed
+25167
No files matched your search
Generated
+112
@@ -0,0 +1,112 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "fake-eye-server"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fake-eye-server"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
description = "Pretends to be the Steam Frame's eye tracker (shared-memory layout v4) so frameeyeosc can run on a PC"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
libc = "0.2"
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
@@ -0,0 +1,381 @@
|
||||
//! Pretends to be the Steam Frame's eye tracker, so the real frameeyeosc (and its Eye panel) can run on
|
||||
//! an ordinary Linux PC. It creates /dev/shm/eye-server.mmap with the private version-4 layout that
|
||||
//! frameeyeosc reads (see frameeyeosc's src/main.rs) and publishes made-up samples about 120 times a second:
|
||||
//! eyes that look around, blink and now and then wink.
|
||||
//!
|
||||
//! During the Eye panel's eye fit it plays along: it reads the capture requests the panel writes into
|
||||
//! config.json and looks at each dot (or closes its eyes), like a person following the fit would.
|
||||
//! Its "sensor" is deliberately a little off (reads low and a bit narrow vertically), so the fit has
|
||||
//! something to correct.
|
||||
//!
|
||||
//! fake-eye-server [--rate HZ] [--no-bias] [--config PATH]
|
||||
//! Stop with Ctrl+C; the shared-memory file is removed on exit.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const PATH: &str = "/dev/shm/eye-server.mmap";
|
||||
const SHM_SIZE: usize = 0x4f21a;
|
||||
const VERSION: u32 = 4;
|
||||
const OFF_INITIALIZED: usize = 0x04;
|
||||
const OFF_MUTEX: usize = 0x08;
|
||||
const OFF_SEQUENCE: usize = 0x38;
|
||||
const OFF_REQUESTED: usize = 0x3c;
|
||||
const OFF_RECORD: usize = 0x152;
|
||||
// Inside the packed record
|
||||
const REC_STATE: usize = 0x00;
|
||||
const REC_FLAG: usize = 0x04;
|
||||
const REC_TIME: usize = 0x05;
|
||||
const REC_GAZE: usize = 0x0d;
|
||||
const REC_GAZE_COV: usize = 0x25;
|
||||
const REC_FIXATION: usize = 0x3d;
|
||||
const REC_PRE_GAZE: usize = 0x49;
|
||||
const REC_PRE_COV: usize = 0x61;
|
||||
const REC_OPENNESS: usize = 0x79;
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<libc::pthread_mutex_t>() <= 0x30);
|
||||
|
||||
static STOP: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
extern "C" fn on_signal(_: libc::c_int) {
|
||||
STOP.store(true, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
struct Shm {
|
||||
base: *mut u8,
|
||||
}
|
||||
|
||||
impl Shm {
|
||||
fn create() -> io::Result<Self> {
|
||||
let _ = std::fs::remove_file(PATH);
|
||||
let file = OpenOptions::new().read(true).write(true).create_new(true).mode(0o666).open(PATH)?;
|
||||
file.set_len(SHM_SIZE as u64)?;
|
||||
use std::os::fd::AsRawFd;
|
||||
let base = unsafe {
|
||||
libc::mmap(ptr::null_mut(), SHM_SIZE, libc::PROT_READ | libc::PROT_WRITE, libc::MAP_SHARED, file.as_raw_fd(), 0)
|
||||
};
|
||||
if base == libc::MAP_FAILED {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let shm = Shm { base: base.cast() };
|
||||
unsafe {
|
||||
// A robust, process-shared mutex, like the real eye server's
|
||||
let mut attr: libc::pthread_mutexattr_t = std::mem::zeroed();
|
||||
libc::pthread_mutexattr_init(&mut attr);
|
||||
libc::pthread_mutexattr_setpshared(&mut attr, libc::PTHREAD_PROCESS_SHARED);
|
||||
libc::pthread_mutexattr_setrobust(&mut attr, libc::PTHREAD_MUTEX_ROBUST);
|
||||
libc::pthread_mutex_init(shm.mutex(), &attr);
|
||||
libc::pthread_mutexattr_destroy(&mut attr);
|
||||
shm.write_u32(0, VERSION);
|
||||
shm.write_u32(OFF_INITIALIZED, 1);
|
||||
}
|
||||
Ok(shm)
|
||||
}
|
||||
|
||||
fn mutex(&self) -> *mut libc::pthread_mutex_t {
|
||||
unsafe { self.base.add(OFF_MUTEX).cast() }
|
||||
}
|
||||
|
||||
unsafe fn write_u32(&self, offset: usize, value: u32) {
|
||||
unsafe { ptr::write_volatile(self.base.add(offset).cast::<u32>(), value) };
|
||||
}
|
||||
|
||||
unsafe fn write_bytes(&self, offset: usize, bytes: &[u8]) {
|
||||
unsafe { ptr::copy_nonoverlapping(bytes.as_ptr(), self.base.add(offset), bytes.len()) };
|
||||
}
|
||||
|
||||
fn publish(&self, sample: &Sample) {
|
||||
let record = OFF_RECORD;
|
||||
let floats = |values: &[f32]| -> Vec<u8> { values.iter().flat_map(|value| value.to_le_bytes()).collect() };
|
||||
unsafe {
|
||||
let code = libc::pthread_mutex_lock(self.mutex());
|
||||
if code == libc::EOWNERDEAD {
|
||||
libc::pthread_mutex_consistent(self.mutex());
|
||||
}
|
||||
self.write_bytes(record + REC_STATE, &1u32.to_le_bytes());
|
||||
self.write_bytes(record + REC_FLAG, &[1]);
|
||||
self.write_bytes(record + REC_TIME, &sample.time.to_le_bytes());
|
||||
let gaze = [sample.left[0], sample.left[1], sample.left[2], sample.right[0], sample.right[1], sample.right[2]];
|
||||
self.write_bytes(record + REC_GAZE, &floats(&gaze));
|
||||
self.write_bytes(record + REC_GAZE_COV, &floats(&[0.005; 6]));
|
||||
self.write_bytes(record + REC_FIXATION, &floats(&sample.fixation));
|
||||
self.write_bytes(record + REC_PRE_GAZE, &floats(&gaze));
|
||||
self.write_bytes(record + REC_PRE_COV, &floats(&[0.005; 6]));
|
||||
self.write_bytes(record + REC_OPENNESS, &floats(&sample.openness));
|
||||
let sequence = self.base.add(OFF_SEQUENCE).cast::<u32>();
|
||||
ptr::write_volatile(sequence, ptr::read_volatile(sequence).wrapping_add(1));
|
||||
self.write_u32(OFF_REQUESTED, 0);
|
||||
libc::pthread_mutex_unlock(self.mutex());
|
||||
// Shared (not private) futex: frameeyeosc waits on this word from another process
|
||||
libc::syscall(libc::SYS_futex, sequence, libc::FUTEX_WAKE, i32::MAX, ptr::null::<libc::timespec>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Shm {
|
||||
fn drop(&mut self) {
|
||||
unsafe { libc::munmap(self.base.cast(), SHM_SIZE) };
|
||||
let _ = std::fs::remove_file(PATH);
|
||||
}
|
||||
}
|
||||
|
||||
struct Sample {
|
||||
time: f64,
|
||||
left: [f32; 3],
|
||||
right: [f32; 3],
|
||||
fixation: [f32; 3],
|
||||
openness: [f32; 2],
|
||||
}
|
||||
|
||||
/// A small deterministic pseudo-random generator (xorshift), so no extra crate is needed.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f32 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
(self.0 >> 40) as f32 / (1u64 << 24) as f32
|
||||
}
|
||||
|
||||
fn noise(&mut self, size: f32) -> f32 {
|
||||
(self.next() + self.next() + self.next() - 1.5) * size
|
||||
}
|
||||
}
|
||||
|
||||
/// What the pretend person is doing.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Look {
|
||||
Wander,
|
||||
/// Following an eye-fit dot: degrees right, degrees up.
|
||||
Target(f32, f32),
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Reads the eye-fit capture the panel asked for (config.json `gaze_capture`) and whether frameeyeosc
|
||||
/// finished it (status.json).
|
||||
struct FitWatcher {
|
||||
config: Option<PathBuf>,
|
||||
status: Option<PathBuf>,
|
||||
last_check: Instant,
|
||||
look: Look,
|
||||
/// Until when to keep holding the look after a capture finished (the dot is still up, gliding on).
|
||||
hold_until: Option<Instant>,
|
||||
active_id: i64,
|
||||
}
|
||||
|
||||
impl FitWatcher {
|
||||
fn new(config: Option<PathBuf>) -> Self {
|
||||
let config = config.or_else(|| {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
|
||||
Some(base.join("frameeyeosc").join("config.json"))
|
||||
});
|
||||
let status = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| Some(PathBuf::from(format!("/run/user/{}", unsafe { libc::getuid() }))))
|
||||
.map(|dir| dir.join("frameeyeosc").join("status.json"));
|
||||
Self {
|
||||
config,
|
||||
status,
|
||||
last_check: Instant::now() - Duration::from_secs(1),
|
||||
look: Look::Wander,
|
||||
hold_until: None,
|
||||
active_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json(path: &Option<PathBuf>) -> Option<serde_json::Value> {
|
||||
let text = std::fs::read_to_string(path.as_ref()?).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
fn update(&mut self) -> Look {
|
||||
if self.last_check.elapsed() < Duration::from_millis(50) {
|
||||
return self.current();
|
||||
}
|
||||
self.last_check = Instant::now();
|
||||
let request = Self::read_json(&self.config).and_then(|config| config.get("gaze_capture").cloned());
|
||||
let id = request.as_ref().and_then(|request| request.get("id")).and_then(|id| id.as_i64()).unwrap_or(0);
|
||||
let target = request.as_ref().and_then(|request| request.get("target")).and_then(|t| t.as_str()).unwrap_or("");
|
||||
let done_id = Self::read_json(&self.status)
|
||||
.and_then(|status| status.get("gaze_capture").cloned())
|
||||
.filter(|capture| capture.get("state").and_then(|state| state.as_str()) == Some("done"))
|
||||
.and_then(|capture| capture.get("id").and_then(|id| id.as_i64()))
|
||||
.unwrap_or(0);
|
||||
if id != 0 && id != done_id {
|
||||
// A capture is running (or about to): look where it wants
|
||||
if id != self.active_id {
|
||||
self.active_id = id;
|
||||
eprintln!("[fake-eye-server] eye fit asks for \"{target}\" (capture {id}); looking there");
|
||||
}
|
||||
self.look = match target {
|
||||
"center" => Look::Target(0.0, 0.0),
|
||||
"up" => Look::Target(0.0, 15.0),
|
||||
"down" => Look::Target(0.0, -15.0),
|
||||
"left" => Look::Target(-20.0, 0.0),
|
||||
"right" => Look::Target(20.0, 0.0),
|
||||
"closed" => Look::Closed,
|
||||
_ => self.look,
|
||||
};
|
||||
self.hold_until = Some(Instant::now() + Duration::from_secs(3));
|
||||
} else if self.look == Look::Closed && id == done_id {
|
||||
// The eyes-shut step is over: open them again
|
||||
self.look = Look::Wander;
|
||||
self.hold_until = None;
|
||||
}
|
||||
self.current()
|
||||
}
|
||||
|
||||
fn current(&mut self) -> Look {
|
||||
match self.hold_until {
|
||||
Some(until) if Instant::now() > until => {
|
||||
self.hold_until = None;
|
||||
self.look = Look::Wander;
|
||||
Look::Wander
|
||||
}
|
||||
_ => self.look,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn direction(yaw_deg: f32, pitch_deg: f32) -> [f32; 3] {
|
||||
let (x, y) = (yaw_deg.to_radians().tan(), pitch_deg.to_radians().tan());
|
||||
let length = (x * x + y * y + 1.0).sqrt();
|
||||
[x / length, y / length, -1.0 / length]
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut rate = 120.0f64;
|
||||
let mut bias = true;
|
||||
let mut config = None;
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--rate" if i + 1 < args.len() => {
|
||||
rate = args[i + 1].parse().unwrap_or(rate).clamp(5.0, 500.0);
|
||||
i += 1;
|
||||
}
|
||||
"--no-bias" => bias = false,
|
||||
"--config" if i + 1 < args.len() => {
|
||||
config = Some(PathBuf::from(&args[i + 1]));
|
||||
i += 1;
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
println!("fake-eye-server [--rate HZ] [--no-bias] [--config PATH]\nPretends to be the Steam Frame's eye tracker at {PATH}.");
|
||||
return;
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown option {other}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
unsafe {
|
||||
libc::signal(libc::SIGINT, on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t);
|
||||
libc::signal(libc::SIGTERM, on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t);
|
||||
}
|
||||
let shm = match Shm::create() {
|
||||
Ok(shm) => shm,
|
||||
Err(error) => {
|
||||
eprintln!("[fake-eye-server] can't create {PATH}: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
eprintln!("[fake-eye-server] publishing made-up eye samples at {rate:.0} Hz to {PATH} (Ctrl+C to stop)");
|
||||
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15);
|
||||
let mut fit = FitWatcher::new(config);
|
||||
let start = Instant::now();
|
||||
let period = Duration::from_secs_f64(1.0 / rate);
|
||||
let mut next = Instant::now();
|
||||
// Wandering: fixations at random points, with quick saccades between them
|
||||
let (mut yaw, mut pitch) = (0.0f32, 0.0f32);
|
||||
let (mut goal_yaw, mut goal_pitch) = (0.0f32, 0.0f32);
|
||||
let mut next_saccade = 0.8f64;
|
||||
let mut next_blink = 2.5f64;
|
||||
let mut blink_start = -10.0f64;
|
||||
let mut wink_start = -10.0f64;
|
||||
let mut next_wink = 17.0f64;
|
||||
let (mut open_left, mut open_right) = (0.84f32, 0.84f32);
|
||||
|
||||
while !STOP.load(Ordering::SeqCst) {
|
||||
let t = start.elapsed().as_secs_f64();
|
||||
let look = fit.update();
|
||||
let (target_yaw, target_pitch, shut) = match look {
|
||||
Look::Wander => {
|
||||
if t > next_saccade {
|
||||
goal_yaw = rng.noise(34.0);
|
||||
goal_pitch = rng.noise(20.0) - 2.0;
|
||||
next_saccade = t + 0.6 + rng.next() as f64 * 1.6;
|
||||
}
|
||||
(goal_yaw, goal_pitch, false)
|
||||
}
|
||||
Look::Target(y, p) => (y, p, false),
|
||||
Look::Closed => (0.0, 0.0, true),
|
||||
};
|
||||
// Saccade: cover most of the way within ~50 ms
|
||||
let k = 1.0 - (-(1.0 / rate as f32) * 45.0).exp();
|
||||
yaw += (target_yaw - yaw) * k;
|
||||
pitch += (target_pitch - pitch) * k;
|
||||
|
||||
// Blinks (not while following a fit dot) and a wink now and then
|
||||
if look == Look::Wander && t > next_blink {
|
||||
blink_start = t;
|
||||
next_blink = t + 2.5 + rng.next() as f64 * 3.5;
|
||||
}
|
||||
if look == Look::Wander && t > next_wink {
|
||||
wink_start = t;
|
||||
next_wink = t + 15.0 + rng.next() as f64 * 10.0;
|
||||
}
|
||||
let blink = |since: f64| -> f32 {
|
||||
let s = (t - since) as f32;
|
||||
if s < 0.0 || s > 0.26 { 1.0 } else if s < 0.08 { 1.0 - s / 0.08 } else if s < 0.14 { 0.0 } else { (s - 0.14) / 0.12 }
|
||||
};
|
||||
let blinking = blink(blink_start);
|
||||
let winking = if (t - wink_start) < 0.6 { 0.0 } else { 1.0 };
|
||||
// The Frame reads eyes as less open when looking down
|
||||
let relaxed = 0.84 - 0.25 * (-pitch / 20.0).max(0.0);
|
||||
let (goal_left, goal_right) = if shut {
|
||||
(0.18, 0.19)
|
||||
} else {
|
||||
(0.2 + (relaxed - 0.2) * blinking, 0.2 + (relaxed * 1.02 - 0.2) * blinking * winking)
|
||||
};
|
||||
let lid_k = 1.0 - (-(1.0 / rate as f32) * 60.0).exp();
|
||||
open_left += (goal_left - open_left) * lid_k;
|
||||
open_right += (goal_right - open_right) * lid_k;
|
||||
|
||||
// What the "sensor" reports: a little noise, and (unless --no-bias) a bit low and narrow
|
||||
let (sensor_yaw, sensor_pitch) = if bias { (yaw * 0.95 + 1.0, pitch * 0.85 - 2.5) } else { (yaw, pitch) };
|
||||
let (n1, n2, n3, n4) = (rng.noise(0.5), rng.noise(0.5), rng.noise(0.5), rng.noise(0.5));
|
||||
// Each eye turns in a little toward a point about 2 m away
|
||||
let left = direction(sensor_yaw + 0.9 + n1, sensor_pitch + n2);
|
||||
let right = direction(sensor_yaw - 0.9 + n3, sensor_pitch + n4);
|
||||
let center = direction(sensor_yaw + (n1 + n3) / 2.0, sensor_pitch + (n2 + n4) / 2.0);
|
||||
let sample = Sample {
|
||||
time: 1000.0 + t,
|
||||
left,
|
||||
right,
|
||||
fixation: center.map(|c| c * 0.7),
|
||||
openness: [(open_left + rng.noise(0.01)).clamp(0.0, 1.2), (open_right + rng.noise(0.01)).clamp(0.0, 1.2)],
|
||||
};
|
||||
shm.publish(&sample);
|
||||
|
||||
next += period;
|
||||
let now = Instant::now();
|
||||
if next > now {
|
||||
std::thread::sleep(next - now);
|
||||
} else {
|
||||
next = now;
|
||||
}
|
||||
}
|
||||
eprintln!("[fake-eye-server] stopped");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# frameeyeosc-panel as a desktop window, for previewing it on a Linux PC without SteamVR.
|
||||
# Builds the panel's own sources unchanged, except:
|
||||
# - vr_overlay.cpp / vk_texture.cpp (SteamVR and its Vulkan textures) are replaced by vr_overlay_desktop.cpp (SDL2)
|
||||
# - config.h's font paths point at this distribution's Noto Sans CJK (SteamOS keeps them elsewhere)
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(frameeyeosc-panel-preview LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
|
||||
set(UPSTREAM "${CMAKE_CURRENT_SOURCE_DIR}/../../upstream/frameeyeosc" CACHE PATH "frameeyeosc source tree")
|
||||
set(PANEL_FONT_REGULAR "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Regular.ttc" CACHE FILEPATH "Regular font")
|
||||
set(PANEL_FONT_BOLD "/usr/share/fonts/google-noto-sans-cjk-fonts/NotoSansCJK-Bold.ttc" CACHE FILEPATH "Bold font")
|
||||
|
||||
# A copy of the panel's sources with the font paths replaced
|
||||
set(SRC "${CMAKE_BINARY_DIR}/panel-src")
|
||||
file(REMOVE_RECURSE "${SRC}")
|
||||
file(COPY "${UPSTREAM}/panel/src/" DESTINATION "${SRC}")
|
||||
file(READ "${SRC}/config.h" CONFIG_H)
|
||||
string(REPLACE "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc" "${PANEL_FONT_REGULAR}" CONFIG_H "${CONFIG_H}")
|
||||
string(REPLACE "/usr/share/fonts/noto-cjk/NotoSansCJK-Bold.ttc" "${PANEL_FONT_BOLD}" CONFIG_H "${CONFIG_H}")
|
||||
file(WRITE "${SRC}/config.h" "${CONFIG_H}")
|
||||
|
||||
file(STRINGS "${UPSTREAM}/Cargo.toml" CARGO_VERSION_LINE REGEX "^version = \"[^\"]+\"" LIMIT_COUNT 1)
|
||||
string(REGEX REPLACE "^version = \"([^\"]+)\".*" "\\1" FRAMEEYEOSC_VERSION "${CARGO_VERSION_LINE}")
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
find_package(Threads REQUIRED)
|
||||
pkg_check_modules(CAIRO REQUIRED IMPORTED_TARGET cairo)
|
||||
pkg_check_modules(FREETYPE REQUIRED IMPORTED_TARGET freetype2)
|
||||
pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2)
|
||||
# vk_texture.h (included by vr_overlay.h) needs the Vulkan headers only; nothing links Vulkan
|
||||
find_path(VULKAN_INCLUDE vulkan/vulkan.h REQUIRED)
|
||||
|
||||
add_executable(frameeyeosc-panel-preview
|
||||
${SRC}/main.cpp
|
||||
${SRC}/autostart.cpp
|
||||
${SRC}/command.cpp
|
||||
${SRC}/config.cpp
|
||||
${SRC}/draw.cpp
|
||||
${SRC}/gaze_dots.cpp
|
||||
${SRC}/gaze_fit.cpp
|
||||
${SRC}/fit_text.cpp
|
||||
${SRC}/host_entry.cpp
|
||||
${SRC}/i18n.cpp
|
||||
${SRC}/json.cpp
|
||||
${SRC}/model.cpp
|
||||
${SRC}/panel.cpp
|
||||
${SRC}/sounds.cpp
|
||||
${SRC}/status.cpp
|
||||
${SRC}/target.cpp
|
||||
${SRC}/theme.cpp
|
||||
${UPSTREAM}/vendor/frame-updater/cpp/update_check.cpp
|
||||
vr_overlay_desktop.cpp
|
||||
)
|
||||
target_include_directories(frameeyeosc-panel-preview PRIVATE "${SRC}" "${UPSTREAM}/vendor/frame-updater/cpp" "${VULKAN_INCLUDE}")
|
||||
target_compile_definitions(frameeyeosc-panel-preview PRIVATE FRAMEEYEOSC_VERSION="${FRAMEEYEOSC_VERSION}")
|
||||
target_compile_options(frameeyeosc-panel-preview PRIVATE -Wall -Wextra)
|
||||
target_link_libraries(frameeyeosc-panel-preview PRIVATE PkgConfig::CAIRO PkgConfig::FREETYPE PkgConfig::SDL2 Threads::Threads)
|
||||
@@ -0,0 +1,534 @@
|
||||
// A desktop stand-in for frameeyeosc-panel's vr_overlay.cpp: the same panel in an ordinary window (SDL2)
|
||||
// instead of on the SteamVR dashboard, so it can be tried on a PC without a headset. Everything else
|
||||
// (drawing, buttons, settings, the eye fit, the sounds) is frameeyeosc-panel's own code, unchanged.
|
||||
//
|
||||
// While "the dashboard is open" the window shows the panel and the mouse works like the laser pointer.
|
||||
// Space (or the button at the top) closes the dashboard: the window then shows what you would see in the
|
||||
// headset, which is where the eye fit's dot and the debug gaze dots appear.
|
||||
|
||||
#include "vr_overlay.h"
|
||||
|
||||
#include "gaze_fit.h"
|
||||
|
||||
#include <SDL.h>
|
||||
#include <cairo.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// vk_texture.cpp needs OpenVR, so it isn't built here; VrOverlay only holds these as (unused) members.
|
||||
VulkanContext::~VulkanContext() {}
|
||||
OverlayTexture::~OverlayTexture() {}
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kBar = 46; ///< the toolbar above the panel (logical px)
|
||||
constexpr double kPxPerDegree = 12.0; ///< the headset view: 100 degrees across the 1200 px panel width
|
||||
constexpr double kTargetWidthM = 0.3; ///< the eye fit's target is 0.3 m wide, 2 m ahead (see vr_overlay.cpp)
|
||||
constexpr double kTargetDistanceM = 2.0;
|
||||
|
||||
struct Image {
|
||||
std::vector<uint8_t> rgba; ///< non-premultiplied
|
||||
int size = 0;
|
||||
cairo_surface_t* surface = nullptr;
|
||||
|
||||
void set(const uint8_t* data, int edge) {
|
||||
rgba.assign(data, data + static_cast<size_t>(edge) * edge * 4);
|
||||
size = edge;
|
||||
if (surface != nullptr) cairo_surface_destroy(surface);
|
||||
surface = nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
struct Dot {
|
||||
bool shown = false;
|
||||
double x = 0, y = 0, z = -1, width = 0.02;
|
||||
Image image;
|
||||
};
|
||||
|
||||
struct Desktop {
|
||||
SDL_Window* window = nullptr;
|
||||
SDL_Renderer* renderer = nullptr;
|
||||
SDL_Texture* texture = nullptr;
|
||||
cairo_surface_t* frame = nullptr; ///< the whole window: toolbar and view
|
||||
int width = 0; ///< logical size
|
||||
int height = 0;
|
||||
int panelHeight = 0;
|
||||
cairo_surface_t* panel = nullptr;
|
||||
bool dashboardOpen = true;
|
||||
bool pointerInPanel = false;
|
||||
bool buttonHover = false;
|
||||
bool dirty = true;
|
||||
Uint32 lastDraw = 0;
|
||||
bool targetShown = false;
|
||||
double targetYaw = 0, targetPitch = 0;
|
||||
Image target;
|
||||
Dot dots[2];
|
||||
} gDesk;
|
||||
|
||||
/** Non-premultiplied RGBA into a cairo (premultiplied, native-endian ARGB) surface. */
|
||||
cairo_surface_t* toSurface(const uint8_t* rgba, int width, int height) {
|
||||
cairo_surface_t* surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
|
||||
cairo_surface_flush(surface);
|
||||
unsigned char* data = cairo_image_surface_get_data(surface);
|
||||
const int stride = cairo_image_surface_get_stride(surface);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
uint32_t* row = reinterpret_cast<uint32_t*>(data + static_cast<size_t>(y) * stride);
|
||||
const uint8_t* in = rgba + static_cast<size_t>(y) * width * 4;
|
||||
for (int x = 0; x < width; ++x, in += 4) {
|
||||
const uint32_t a = in[3];
|
||||
row[x] = (a << 24) | ((in[0] * a / 255) << 16) | ((in[1] * a / 255) << 8) | (in[2] * a / 255);
|
||||
}
|
||||
}
|
||||
cairo_surface_mark_dirty(surface);
|
||||
return surface;
|
||||
}
|
||||
|
||||
cairo_surface_t* surfaceOf(Image& image) {
|
||||
if (image.surface == nullptr && image.size > 0) image.surface = toSurface(image.rgba.data(), image.size, image.size);
|
||||
return image.surface;
|
||||
}
|
||||
|
||||
void rgb(cairo_t* cr, uint32_t hex, double alpha = 1.0) {
|
||||
cairo_set_source_rgba(cr, ((hex >> 16) & 0xff) / 255.0, ((hex >> 8) & 0xff) / 255.0, (hex & 0xff) / 255.0, alpha);
|
||||
}
|
||||
|
||||
void text(cairo_t* cr, double x, double y, const char* utf8, double size, uint32_t color, bool bold = false,
|
||||
bool centered = false) {
|
||||
cairo_select_font_face(cr, "Noto Sans", CAIRO_FONT_SLANT_NORMAL, bold ? CAIRO_FONT_WEIGHT_BOLD : CAIRO_FONT_WEIGHT_NORMAL);
|
||||
cairo_set_font_size(cr, size);
|
||||
if (centered) {
|
||||
cairo_text_extents_t extents;
|
||||
cairo_text_extents(cr, utf8, &extents);
|
||||
x -= extents.width / 2 + extents.x_bearing;
|
||||
}
|
||||
rgb(cr, color);
|
||||
cairo_move_to(cr, x, y);
|
||||
cairo_show_text(cr, utf8);
|
||||
}
|
||||
|
||||
/** The toolbar button's rectangle (logical px). */
|
||||
void buttonRect(double& x, double& y, double& w, double& h) {
|
||||
w = 250;
|
||||
h = 32;
|
||||
x = gDesk.width - w - 10;
|
||||
y = (kBar - h) / 2;
|
||||
}
|
||||
|
||||
bool inButton(double px, double py) {
|
||||
double x, y, w, h;
|
||||
buttonRect(x, y, w, h);
|
||||
return px >= x && px <= x + w && py >= y && py <= y + h;
|
||||
}
|
||||
|
||||
void roundedRect(cairo_t* cr, double x, double y, double w, double h, double r) {
|
||||
cairo_new_sub_path(cr);
|
||||
cairo_arc(cr, x + w - r, y + r, r, -M_PI / 2, 0);
|
||||
cairo_arc(cr, x + w - r, y + h - r, r, 0, M_PI / 2);
|
||||
cairo_arc(cr, x + r, y + h - r, r, M_PI / 2, M_PI);
|
||||
cairo_arc(cr, x + r, y + r, r, M_PI, 3 * M_PI / 2);
|
||||
cairo_close_path(cr);
|
||||
}
|
||||
|
||||
/** Draw an image centered at a direction in front of you, `degrees` wide. */
|
||||
void drawInView(cairo_t* cr, cairo_surface_t* image, int size, double yawDeg, double pitchDeg, double degrees) {
|
||||
if (image == nullptr || size <= 0) return;
|
||||
const double cx = gDesk.width / 2.0 + yawDeg * kPxPerDegree;
|
||||
const double cy = kBar + gDesk.panelHeight / 2.0 - pitchDeg * kPxPerDegree;
|
||||
const double px = degrees * kPxPerDegree;
|
||||
cairo_save(cr);
|
||||
cairo_translate(cr, cx - px / 2, cy - px / 2);
|
||||
cairo_scale(cr, px / size, px / size);
|
||||
cairo_set_source_surface(cr, image, 0, 0);
|
||||
cairo_pattern_set_filter(cairo_get_source(cr), CAIRO_FILTER_GOOD);
|
||||
cairo_paint(cr);
|
||||
cairo_restore(cr);
|
||||
}
|
||||
|
||||
void drawHeadsetView(cairo_t* cr) {
|
||||
const double top = kBar;
|
||||
const double h = gDesk.panelHeight;
|
||||
const double w = gDesk.width;
|
||||
cairo_pattern_t* sky = cairo_pattern_create_radial(w / 2, top + h / 2, 40, w / 2, top + h / 2, w * 0.75);
|
||||
cairo_pattern_add_color_stop_rgb(sky, 0, 0.09, 0.11, 0.15);
|
||||
cairo_pattern_add_color_stop_rgb(sky, 1, 0.02, 0.03, 0.05);
|
||||
cairo_rectangle(cr, 0, top, w, h);
|
||||
cairo_set_source(cr, sky);
|
||||
cairo_fill(cr);
|
||||
cairo_pattern_destroy(sky);
|
||||
// A faint grid every 10 degrees, brighter through the middle
|
||||
cairo_set_line_width(cr, 1);
|
||||
for (int degree = -50; degree <= 50; degree += 10) {
|
||||
rgb(cr, degree == 0 ? 0x3a4350 : 0x1c222b);
|
||||
const double x = w / 2 + degree * kPxPerDegree + 0.5;
|
||||
cairo_move_to(cr, x, top);
|
||||
cairo_line_to(cr, x, top + h);
|
||||
cairo_stroke(cr);
|
||||
}
|
||||
for (int degree = -30; degree <= 30; degree += 10) {
|
||||
rgb(cr, degree == 0 ? 0x3a4350 : 0x1c222b);
|
||||
const double y = top + h / 2 - degree * kPxPerDegree + 0.5;
|
||||
cairo_move_to(cr, 0, y);
|
||||
cairo_line_to(cr, w, y);
|
||||
cairo_stroke(cr);
|
||||
}
|
||||
text(cr, w / 2 + 6, top + 18, "straight ahead", 12, 0x5d6672);
|
||||
text(cr, 8, top + h / 2 - 6, "eye level", 12, 0x5d6672);
|
||||
|
||||
if (gDesk.targetShown) {
|
||||
const double degrees = 2 * std::atan(kTargetWidthM / 2 / kTargetDistanceM) * 180 / M_PI;
|
||||
drawInView(cr, surfaceOf(gDesk.target), gDesk.target.size, gDesk.targetYaw, gDesk.targetPitch, degrees);
|
||||
}
|
||||
bool anyDot = false;
|
||||
for (Dot& dot : gDesk.dots) {
|
||||
if (!dot.shown) continue;
|
||||
anyDot = true;
|
||||
const double distance = std::sqrt(dot.x * dot.x + dot.y * dot.y + dot.z * dot.z);
|
||||
const double yaw = std::atan2(dot.x, -dot.z) * 180 / M_PI;
|
||||
const double pitch = std::atan2(dot.y, std::sqrt(dot.x * dot.x + dot.z * dot.z)) * 180 / M_PI;
|
||||
const double degrees = 2 * std::atan(dot.width / 2 / std::max(distance, 0.05)) * 180 / M_PI;
|
||||
drawInView(cr, surfaceOf(dot.image), dot.image.size, yaw, pitch, std::max(degrees, 1.2));
|
||||
}
|
||||
|
||||
const double bottom = top + h;
|
||||
text(cr, w / 2, bottom - 44, "The dashboard is closed: this is what you would see in the headset.", 16, 0xc9d1d9, true, true);
|
||||
if (gDesk.targetShown) {
|
||||
text(cr, w / 2, bottom - 20, "The eye fit is running. The pretend eyes follow the dot, like you would with yours.", 14, 0x9198a1,
|
||||
false, true);
|
||||
} else if (anyDot) {
|
||||
text(cr, w / 2, bottom - 20, "The dots show where the eyes being sent to VRChat are looking (Show gaze dots is on).", 14,
|
||||
0x9198a1, false, true);
|
||||
} else {
|
||||
text(cr, w / 2, bottom - 20,
|
||||
"Press Space to open the dashboard again. (Eye fit tab: press Fit my eyes, then close the dashboard.)", 14,
|
||||
0x9198a1, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
void redraw() {
|
||||
if (gDesk.renderer == nullptr) return;
|
||||
cairo_t* cr = cairo_create(gDesk.frame);
|
||||
// Toolbar
|
||||
rgb(cr, 0x0d1117);
|
||||
cairo_rectangle(cr, 0, 0, gDesk.width, kBar);
|
||||
cairo_fill(cr);
|
||||
rgb(cr, 0x30363d);
|
||||
cairo_rectangle(cr, 0, kBar - 1, gDesk.width, 1);
|
||||
cairo_fill(cr);
|
||||
text(cr, 14, 20, "Eye panel preview", 15, 0xe6edf3, true);
|
||||
text(cr, 14, 38, "A pretend Steam Frame on this PC. Nothing is sent to VRChat.", 12.5, 0x9198a1);
|
||||
double bx, by, bw, bh;
|
||||
buttonRect(bx, by, bw, bh);
|
||||
roundedRect(cr, bx, by, bw, bh, 7);
|
||||
rgb(cr, gDesk.buttonHover ? 0xc45fe0 : 0xe27dfd);
|
||||
cairo_fill(cr);
|
||||
text(cr, bx + bw / 2, by + 21, gDesk.dashboardOpen ? "Close the dashboard (Space)" : "Open the dashboard (Space)", 14,
|
||||
0x0d1117, true, true);
|
||||
// The view
|
||||
if (gDesk.dashboardOpen) {
|
||||
rgb(cr, 0x0d1117);
|
||||
cairo_rectangle(cr, 0, kBar, gDesk.width, gDesk.panelHeight);
|
||||
cairo_fill(cr);
|
||||
if (gDesk.panel != nullptr) {
|
||||
cairo_set_source_surface(cr, gDesk.panel, 0, kBar);
|
||||
cairo_paint(cr);
|
||||
}
|
||||
} else {
|
||||
drawHeadsetView(cr);
|
||||
}
|
||||
cairo_destroy(cr);
|
||||
cairo_surface_flush(gDesk.frame);
|
||||
SDL_UpdateTexture(gDesk.texture, nullptr, cairo_image_surface_get_data(gDesk.frame),
|
||||
cairo_image_surface_get_stride(gDesk.frame));
|
||||
SDL_SetRenderDrawColor(gDesk.renderer, 0x0d, 0x11, 0x17, 0xff);
|
||||
SDL_RenderClear(gDesk.renderer);
|
||||
SDL_RenderCopy(gDesk.renderer, gDesk.texture, nullptr, nullptr);
|
||||
SDL_RenderPresent(gDesk.renderer);
|
||||
gDesk.dirty = false;
|
||||
gDesk.lastDraw = SDL_GetTicks();
|
||||
}
|
||||
|
||||
/**
|
||||
* For testing and for taking pictures: PREVIEW_SCRIPT runs simple steps separated by ";":
|
||||
* wait SECONDS | click X,Y (panel px) | key space | shot FILE.png | quit
|
||||
*/
|
||||
struct Script {
|
||||
std::vector<std::string> steps;
|
||||
size_t next = 0;
|
||||
Uint32 resumeAt = 0;
|
||||
bool loaded = false;
|
||||
} gScript;
|
||||
|
||||
void setDashboard(bool open, VrEvents& events);
|
||||
|
||||
void runScript(VrEvents& events) {
|
||||
if (!gScript.loaded) {
|
||||
gScript.loaded = true;
|
||||
if (const char* script = std::getenv("PREVIEW_SCRIPT")) {
|
||||
std::string all = script;
|
||||
size_t start = 0;
|
||||
while (start <= all.size()) {
|
||||
const size_t end = all.find(';', start);
|
||||
std::string step = all.substr(start, end == std::string::npos ? std::string::npos : end - start);
|
||||
const size_t first = step.find_first_not_of(" \t");
|
||||
if (first != std::string::npos) gScript.steps.push_back(step.substr(first));
|
||||
if (end == std::string::npos) break;
|
||||
start = end + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
while (gScript.next < gScript.steps.size() && SDL_GetTicks() >= gScript.resumeAt) {
|
||||
const std::string step = gScript.steps[gScript.next++];
|
||||
double x = 0, y = 0, seconds = 0;
|
||||
char path[512] = {};
|
||||
if (std::sscanf(step.c_str(), "wait %lf", &seconds) == 1) {
|
||||
gScript.resumeAt = SDL_GetTicks() + static_cast<Uint32>(seconds * 1000);
|
||||
} else if (std::sscanf(step.c_str(), "click %lf,%lf", &x, &y) == 2) {
|
||||
events.pointer.push_back({PointerInput::Type::Move, x, y});
|
||||
events.pointer.push_back({PointerInput::Type::Down, x, y});
|
||||
events.pointer.push_back({PointerInput::Type::Up, x, y});
|
||||
gScript.resumeAt = SDL_GetTicks() + 150;
|
||||
} else if (step == "key space") {
|
||||
setDashboard(!gDesk.dashboardOpen, events);
|
||||
} else if (std::sscanf(step.c_str(), "shot %511s", path) == 1) {
|
||||
redraw();
|
||||
const cairo_status_t result = cairo_surface_write_to_png(gDesk.frame, path);
|
||||
std::fprintf(stderr, "[preview] %s %s\n", result == CAIRO_STATUS_SUCCESS ? "saved" : "could not save", path);
|
||||
} else if (step == "quit") {
|
||||
events.closeRequested = true;
|
||||
} else {
|
||||
std::fprintf(stderr, "[preview] unknown script step: %s\n", step.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void setDashboard(bool open, VrEvents& events) {
|
||||
if (gDesk.dashboardOpen == open) return;
|
||||
gDesk.dashboardOpen = open;
|
||||
if (!open && gDesk.pointerInPanel) {
|
||||
events.pointer.push_back({PointerInput::Type::Leave, 0, 0});
|
||||
gDesk.pointerInPanel = false;
|
||||
}
|
||||
std::fprintf(stderr, "[preview] dashboard %s\n", open ? "opened" : "closed");
|
||||
gDesk.dirty = true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
VrOverlay::VrOverlay() = default;
|
||||
|
||||
VrOverlay::~VrOverlay() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
VrOverlay::ConnectResult VrOverlay::connect(int width, int height, std::string& message) {
|
||||
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");
|
||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) != 0) {
|
||||
message = std::string("SDL: ") + SDL_GetError();
|
||||
return ConnectResult::Error;
|
||||
}
|
||||
gDesk.width = width;
|
||||
gDesk.panelHeight = height;
|
||||
gDesk.height = height + kBar;
|
||||
gDesk.window = SDL_CreateWindow("Eye panel preview (frameeyeosc)", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
gDesk.width, gDesk.height, SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI);
|
||||
if (gDesk.window == nullptr) {
|
||||
message = std::string("SDL window: ") + SDL_GetError();
|
||||
SDL_Quit();
|
||||
return ConnectResult::Error;
|
||||
}
|
||||
gDesk.renderer = SDL_CreateRenderer(gDesk.window, -1, SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
|
||||
if (gDesk.renderer == nullptr) gDesk.renderer = SDL_CreateRenderer(gDesk.window, -1, SDL_RENDERER_SOFTWARE);
|
||||
if (gDesk.renderer == nullptr) {
|
||||
message = std::string("SDL renderer: ") + SDL_GetError();
|
||||
return ConnectResult::Error;
|
||||
}
|
||||
SDL_RenderSetLogicalSize(gDesk.renderer, gDesk.width, gDesk.height);
|
||||
gDesk.texture = SDL_CreateTexture(gDesk.renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, gDesk.width,
|
||||
gDesk.height);
|
||||
gDesk.frame = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, gDesk.width, gDesk.height);
|
||||
connected_ = true;
|
||||
panelHeight_ = height;
|
||||
std::fprintf(stderr, "[preview] window %dx%d (the panel plus a %d px toolbar)\n", gDesk.width, gDesk.height, kBar);
|
||||
redraw();
|
||||
return ConnectResult::Ok;
|
||||
}
|
||||
|
||||
void VrOverlay::shutdown() {
|
||||
if (!connected_) return;
|
||||
connected_ = false;
|
||||
for (Image* image : {&gDesk.target, &gDesk.dots[0].image, &gDesk.dots[1].image}) {
|
||||
if (image->surface != nullptr) cairo_surface_destroy(image->surface);
|
||||
image->surface = nullptr;
|
||||
}
|
||||
if (gDesk.panel != nullptr) cairo_surface_destroy(gDesk.panel);
|
||||
if (gDesk.frame != nullptr) cairo_surface_destroy(gDesk.frame);
|
||||
gDesk.panel = gDesk.frame = nullptr;
|
||||
if (gDesk.texture != nullptr) SDL_DestroyTexture(gDesk.texture);
|
||||
if (gDesk.renderer != nullptr) SDL_DestroyRenderer(gDesk.renderer);
|
||||
if (gDesk.window != nullptr) SDL_DestroyWindow(gDesk.window);
|
||||
gDesk.texture = nullptr;
|
||||
gDesk.renderer = nullptr;
|
||||
gDesk.window = nullptr;
|
||||
SDL_Quit();
|
||||
std::fprintf(stderr, "[preview] window closed\n");
|
||||
}
|
||||
|
||||
VrEvents VrOverlay::pollEvents() {
|
||||
VrEvents events;
|
||||
SDL_Event event;
|
||||
while (SDL_PollEvent(&event)) {
|
||||
switch (event.type) {
|
||||
case SDL_QUIT:
|
||||
// Like the dashboard icon's "close": the panel quits
|
||||
events.closeRequested = true;
|
||||
break;
|
||||
case SDL_KEYDOWN:
|
||||
if (event.key.repeat == 0 && (event.key.keysym.sym == SDLK_SPACE || event.key.keysym.sym == SDLK_TAB ||
|
||||
event.key.keysym.sym == SDLK_ESCAPE)) {
|
||||
setDashboard(event.key.keysym.sym == SDLK_ESCAPE ? false : !gDesk.dashboardOpen, events);
|
||||
}
|
||||
break;
|
||||
case SDL_MOUSEMOTION: {
|
||||
const double x = event.motion.x, y = event.motion.y;
|
||||
const bool hover = inButton(x, y);
|
||||
if (hover != gDesk.buttonHover) {
|
||||
gDesk.buttonHover = hover;
|
||||
gDesk.dirty = true;
|
||||
}
|
||||
if (gDesk.dashboardOpen && y >= kBar) {
|
||||
events.pointer.push_back({PointerInput::Type::Move, x, y - kBar});
|
||||
gDesk.pointerInPanel = true;
|
||||
} else if (gDesk.pointerInPanel) {
|
||||
events.pointer.push_back({PointerInput::Type::Leave, 0, 0});
|
||||
gDesk.pointerInPanel = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SDL_MOUSEBUTTONDOWN:
|
||||
if (event.button.button != SDL_BUTTON_LEFT) break;
|
||||
if (inButton(event.button.x, event.button.y)) {
|
||||
setDashboard(!gDesk.dashboardOpen, events);
|
||||
} else if (gDesk.dashboardOpen && event.button.y >= kBar) {
|
||||
events.pointer.push_back({PointerInput::Type::Down, double(event.button.x), double(event.button.y - kBar)});
|
||||
} else if (!gDesk.dashboardOpen && event.button.y >= kBar) {
|
||||
// Clicking into the headset view opens the dashboard again, like the system button
|
||||
setDashboard(true, events);
|
||||
}
|
||||
break;
|
||||
case SDL_MOUSEBUTTONUP:
|
||||
if (event.button.button == SDL_BUTTON_LEFT && gDesk.dashboardOpen && event.button.y >= kBar) {
|
||||
events.pointer.push_back({PointerInput::Type::Up, double(event.button.x), double(event.button.y - kBar)});
|
||||
}
|
||||
break;
|
||||
case SDL_WINDOWEVENT:
|
||||
if (event.window.event == SDL_WINDOWEVENT_LEAVE && gDesk.pointerInPanel) {
|
||||
events.pointer.push_back({PointerInput::Type::Leave, 0, 0});
|
||||
gDesk.pointerInPanel = false;
|
||||
}
|
||||
gDesk.dirty = true;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
runScript(events);
|
||||
// The headset view moves (the fit's dot, the gaze dots) without the panel changing, so it is redrawn
|
||||
// regularly; the panel view only when something changed.
|
||||
const Uint32 now = SDL_GetTicks();
|
||||
if (gDesk.dirty || (!gDesk.dashboardOpen && now - gDesk.lastDraw >= 11) || now - gDesk.lastDraw >= 500) redraw();
|
||||
return events;
|
||||
}
|
||||
|
||||
bool VrOverlay::steamVrAlive() const {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VrOverlay::panelVisible() const {
|
||||
return gDesk.dashboardOpen;
|
||||
}
|
||||
|
||||
void VrOverlay::showPanel() {
|
||||
VrEvents ignored;
|
||||
setDashboard(true, ignored);
|
||||
if (gDesk.window != nullptr) SDL_RaiseWindow(gDesk.window);
|
||||
}
|
||||
|
||||
bool VrOverlay::dashboardVisible() const {
|
||||
return gDesk.dashboardOpen;
|
||||
}
|
||||
|
||||
double VrOverlay::userIpdMeters() const {
|
||||
return gaze_fit::kDefaultIpdM;
|
||||
}
|
||||
|
||||
bool VrOverlay::showTarget(double yawDeg, double pitchDeg, const uint8_t* rgba, int size) {
|
||||
if (rgba != nullptr) gDesk.target.set(rgba, size);
|
||||
if (!gDesk.targetShown || yawDeg != gDesk.targetYaw || pitchDeg != gDesk.targetPitch || rgba != nullptr) gDesk.dirty = true;
|
||||
gDesk.targetShown = true;
|
||||
gDesk.targetYaw = yawDeg;
|
||||
gDesk.targetPitch = pitchDeg;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VrOverlay::waitFrameSync(uint32_t /*timeoutMs*/) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void VrOverlay::hideTarget() {
|
||||
if (gDesk.targetShown) gDesk.dirty = true;
|
||||
gDesk.targetShown = false;
|
||||
}
|
||||
|
||||
bool VrOverlay::showDot(int index, double x, double y, double z, double /*yawDeg*/, double /*pitchDeg*/, const uint8_t* rgba,
|
||||
int size, bool newImage, double widthM) {
|
||||
if (index < 0 || index > 1) return false;
|
||||
Dot& dot = gDesk.dots[index];
|
||||
if (newImage || dot.image.size == 0) dot.image.set(rgba, size);
|
||||
dot.shown = true;
|
||||
dot.x = x;
|
||||
dot.y = y;
|
||||
dot.z = z;
|
||||
dot.width = widthM;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrOverlay::hideDot(int index) {
|
||||
if (index >= 0 && index <= 1) gDesk.dots[index].shown = false;
|
||||
}
|
||||
|
||||
bool VrOverlay::submitThumbnail(const uint8_t* rgba, int size) {
|
||||
if (gDesk.window == nullptr) return false;
|
||||
// The dashboard thumbnail (the eye) becomes the window icon
|
||||
SDL_Surface* icon = SDL_CreateRGBSurfaceWithFormatFrom(const_cast<uint8_t*>(rgba), size, size, 32, size * 4,
|
||||
SDL_PIXELFORMAT_RGBA32);
|
||||
if (icon == nullptr) return false;
|
||||
SDL_SetWindowIcon(gDesk.window, icon);
|
||||
SDL_FreeSurface(icon);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool VrOverlay::submitPanel(const uint8_t* rgba) {
|
||||
if (gDesk.panel != nullptr) cairo_surface_destroy(gDesk.panel);
|
||||
gDesk.panel = toSurface(rgba, gDesk.width, gDesk.panelHeight);
|
||||
gDesk.dirty = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void VrOverlay::logOverlayState(const char* when) const {
|
||||
std::fprintf(stderr, "[preview] panel shown in a desktop window (%s)\n", when);
|
||||
}
|
||||
|
||||
int VrOverlay::probe() {
|
||||
std::printf("--probe needs SteamVR; this is the desktop preview build\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
int VrOverlay::switchAway(double /*seconds*/) {
|
||||
std::printf("--probe-switch-away needs SteamVR; this is the desktop preview build\n");
|
||||
return 1;
|
||||
}
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# Try the Steam Frame's "Eye" panel (frameeyeosc-panel) on this Linux PC, with a pretend headset:
|
||||
# fake-eye-server plays the Frame's eye tracker (made-up eyes that look around and blink)
|
||||
# frameeyeosc the real program that runs on the headset (built for this PC)
|
||||
# the Eye panel the real panel, in a window instead of the SteamVR dashboard
|
||||
# Everything lives in a scratch folder (settings, status), so nothing on this PC is changed, and
|
||||
# frameeyeosc runs in its own empty network, so it can't reach VRChat or anything else.
|
||||
#
|
||||
# ./run-panel-preview.sh start (close the window or press "Quit app" in the panel to stop)
|
||||
# ./run-panel-preview.sh --reset start over with default settings
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
bin="$here/bin"
|
||||
for program in fake-eye-server frameeyeosc frameeyeosc-panel-preview; do
|
||||
if [[ ! -x "$bin/$program" ]]; then
|
||||
echo "Missing $bin/$program. Build it first (see README.md)." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
runtime="${XDG_RUNTIME_DIR:-/tmp}"
|
||||
sandbox="$runtime/frameeye-preview"
|
||||
if [[ "${1:-}" == "--reset" ]]; then
|
||||
rm -rf "$sandbox"
|
||||
echo "Settings reset."
|
||||
fi
|
||||
mkdir -p "$sandbox/home" "$sandbox/config/frameeyeosc" "$sandbox/run"
|
||||
chmod 700 "$sandbox" "$sandbox/run"
|
||||
# One preview at a time: a second pretend eye tracker would replace the first one's shared memory
|
||||
exec 9>"$sandbox/.lock"
|
||||
if ! flock -n 9; then
|
||||
echo "The Eye panel preview is already open."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# First start: send to "this PC" as a fixed address, as if the headset streamed to it
|
||||
config="$sandbox/config/frameeyeosc/config.json"
|
||||
if [[ ! -f "$config" ]]; then
|
||||
printf '{\n "host": "127.0.0.1"\n}\n' > "$config"
|
||||
fi
|
||||
|
||||
# The panel's update button calls this; in the preview it only ever says "up to date"
|
||||
updater="$sandbox/home/.local/share/frameeyeosc/frame-update.sh"
|
||||
mkdir -p "$(dirname "$updater")"
|
||||
cat > "$updater" <<'EOF'
|
||||
#!/bin/sh
|
||||
# Preview stand-in for frameeyeosc's updater: never downloads or installs anything.
|
||||
current=0.5.3
|
||||
while [ $# -gt 0 ]; do [ "$1" = "--current" ] && current="$2"; shift; done
|
||||
printf '{"status":"up-to-date","current":"%s","latest":"%s","url":"https://github.com/sasaken1102r/frameeyeosc/releases","cached":false,"checked_at":%s}\n' \
|
||||
"$current" "$current" "$(date +%s)"
|
||||
EOF
|
||||
chmod 755 "$updater"
|
||||
|
||||
env_sandbox=(HOME="$sandbox/home" XDG_CONFIG_HOME="$sandbox/config" XDG_CACHE_HOME="$sandbox/home/.cache")
|
||||
pids=()
|
||||
cleanup() {
|
||||
for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null || true; done
|
||||
wait 2>/dev/null || true
|
||||
echo "Preview stopped."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "Starting the pretend eye tracker..."
|
||||
env "${env_sandbox[@]}" XDG_RUNTIME_DIR="$sandbox/run" "$bin/fake-eye-server" &
|
||||
pids+=($!)
|
||||
sleep 0.3
|
||||
|
||||
echo "Starting frameeyeosc (in its own empty network, so nothing reaches VRChat)..."
|
||||
isolate=(unshare --user --map-root-user --net sh -c 'ip link set lo up 2>/dev/null; exec "$0" "$@"')
|
||||
if ! unshare --user --map-root-user --net true 2>/dev/null; then
|
||||
# No user namespaces: pin the destination to a port nothing uses, and lock it from the panel instead
|
||||
echo " (can't isolate its network here; sending to 127.0.0.1:9019 instead, locked)"
|
||||
isolate=()
|
||||
daemon_args=(--target 127.0.0.1:9019)
|
||||
else
|
||||
daemon_args=()
|
||||
fi
|
||||
env "${env_sandbox[@]}" XDG_RUNTIME_DIR="$sandbox/run" "${isolate[@]}" "$bin/frameeyeosc" "${daemon_args[@]}" &
|
||||
pids+=($!)
|
||||
sleep 0.5
|
||||
|
||||
echo "Opening the Eye panel. Space closes/opens the pretend dashboard."
|
||||
# The panel keeps the real runtime dir for the window (Wayland) and sounds (PipeWire); its settings
|
||||
# and status come from the scratch folder
|
||||
env "${env_sandbox[@]}" "$bin/frameeyeosc-panel-preview" --status "$sandbox/run/frameeyeosc/status.json" || true
|
||||
Reference in new issue
Block a user