Compare commits

..
Author SHA1 Message Date
DeeJanuzandClaude Opus 5.5 9e7c12e0d4 Pointer helper: split the member functions into files by topic
Structure only, no behaviour change; the code moves as it is (git's
--color-moved shows it). build.sh compiles ft-pointer.cpp and
pointer_*.cpp.

- pointer.h: the includes, the helpers more than one file uses
  (HandGestures, PoseHistory, FramePanel, SendTo, JsonQuote, OverlayList;
  the three functions are now inline), Clock, Frame, and Pointer.
- ft-pointer.cpp: the header comment, setup (Init, ApplyConfig, and the
  helpers only it uses), the short per-frame sections, and main().
- pointer_input.cpp: waking, the mouse's buttons, the relay's commands.
- pointer_place.cpp: panel placement for layouts.
- pointer_gaze.cpp: gaze mode, the held-back press, the click.
- pointer_holds.cpp: holds (hands, gaze precision buttons).
- pointer_keys.cpp: keyboard clicks.
- pointer_cursor.cpp: recenter, head follow, slow work, the cursor.

Helpers only one file uses stay in its anonymous namespace. Each new file
says that "the top" in its comments is ft-pointer.cpp's header comment.
hands/README.md and update-check.py now name the files that hold the code
they point at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:48:43 -06:00
DeeJanuzandClaude Opus 5.5 7066b591ff Pointer helper: one function per main loop section
Structure only, no behaviour change: every frame does the same work in the
same order. main() now sets up and runs a short loop that calls the
sections, each a Pointer member named after its comment:

  HeadsetOff, GazeAwake, RelayCommands, ReadFrame, ClaimPulse,
  PauseOverlayList, LaserMode, HandRole, LastUsedWins, Recenter, GazeMode,
  Hands, GazePrecisionButtons, KeyboardClicks, CalPanelOpened, HeadSteer,
  PinchesAndGrips, HandsStopped, MarkHeld, HeadFollow, SlowWork, Cursor,
  HeldBackPress, KeyboardClickHold, Click, PollControllerButtons,
  SteamVRQuit

- The values the sections share within a frame (the poses, hmdRaw, tnow,
  eye, and handOk from PinchesAndGrips for HandsStopped) are in a Frame,
  read by ReadFrame at the same point as before. eye moved there from just
  after "last used wins"; it only depends on the poses, read once.
  Sections open with local aliases (hmd, hm, tnow, eye), so the bodies are
  the old code, dedented.
- None of the loop's 43 continues skipped the rest of a frame: 25 were in
  the relay's recvfrom loop and 18 in the controller, press, and overlay
  scans. The recvfrom loop's body is now Command(), where they're returns.
  The quit event's "return 0" is SteamVRQuit() returning true.
- The loop's lambdas are members too: HandAngles, EndHold, StartHold,
  Steer, BeginHold, HeadAngles (taking tnow or the HMD pose as a parameter),
  and Nearest with its Hit type.
- The cursor section is Cursor(): Tilt() while tilting; otherwise
  FindCursor() (collision, drag lock, occlusion; returns a Spot), DrawDot()
  (SteamVR Settings, our dot), and SendRay() (the controller pose).

Built with -Wextra -Wshadow too: no warnings. The three shadowing warnings
the old main() had ("from" twice, "now") are gone with the separate scopes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:46:30 -06:00
DeeJanuzandClaude Opus 5.5 a000725021 Pointer helper: the setup-time lambdas become member functions
Structure only, no behaviour change. The lambdas main() defined before its
loop become Pointer member functions with the same bodies and comments:
Wake, PressLeft, ReleaseLeft, CanAim, AimStart, ChordDrop, LeftButton,
RightButton, MouseMoveHeld, SendPose, HeadPos, Borrow, GiveBack, FindPanel,
GrabProbe, and Place (the names in the file's CamelCase for functions).
sleepMs captured nothing, so it's the free function SleepMs. Every call
site is renamed; the loop's own lambdas stay where they are until the next
commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:38:51 -06:00
DeeJanuzandClaude Opus 5.5 3460e610d1 Pointer helper: runtime state in a Pointer struct
Structure only, no behaviour change. main()'s runtime locals become members
of Pointer, declared in the same order with the same initial values and
comments. The local types (Gaze, Src, Hold, DevicePress, KeyPress) move in
with them, and Clock moves to file scope.

Pointer::Init does the setup, in the original order: settings, VR_Init,
the overlays, the laser width, the sockets, the controller buttons'
manifest, vrhello, the overlay list. Members that were set during setup
(sys, overlay, the three overlay handles, the sockets, systemPointer) are
set there at the same point, and so are the timestamps that started at
"now" (lastVisible, lastDebug, followAt, lastSlow).

The lambdas and the main loop sit unchanged in Pointer::Run for now; the
next commits split them up. applyConfig becomes Pointer::ApplyConfig,
since Init and the "reload" command both call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:37:24 -06:00
DeeJanuzandClaude Opus 5.5 92a72b8797 Pointer helper: settings in a PointerConfig struct
Structure only, no behaviour change. The settings and loadConfig move from
main()'s locals to PointerConfig (pointer/helper/pointer_config.h) with the
same defaults, clamps, and parsing; ReadConfig, ConfDouble, and ParseIgnore
move with them. main() uses them as cfg.<name>.

Head follow and gaze mode stay runtime state. Load only records POINTER_FOLLOW
and POINTER_GAZE; applyConfig, run after each Load (at startup and on
"reload"), turns them on or off when the setting changed, as before, so a
"follow" or "gaze" command still wins until the setting itself changes.
POINTER_LASER_WIDTH is still read once at startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:35:07 -06:00
16 changed files with 2161 additions and 1959 deletions

No files matched your search

+2 -2
View File
@@ -49,7 +49,7 @@ Files, all in `/run/user/UID/frametop-hands/` (private to the user; not `/run/us
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `cam-ring` | ft-camd | `camd/fhring.h` | ft-hands, `tools/ring.py` | | `cam-ring` | ft-camd | `camd/fhring.h` | ft-hands, `tools/ring.py` |
| `hands` | ft-hands | `include/fh_hands.h` | ft-screens (`screens/handcut.cpp`) | | `hands` | ft-hands | `include/fh_hands.h` | ft-screens (`screens/handcut.cpp`) |
| `gestures` | ft-hands | `include/fh_gestures.h` | the pointer helper (`pointer/helper/ft-pointer.cpp`), `tools/watch_gestures.py` | | `gestures` | ft-hands | `include/fh_gestures.h` | the pointer helper (`pointer/helper/pointer_holds.cpp`), `tools/watch_gestures.py` |
The source keeps the `fh_` names and magic strings of frame-hands, the project it started as, so recordings made with it still work. The source keeps the `fh_` names and magic strings of frame-hands, the project it started as, so recordings made with it still work.
@@ -156,7 +156,7 @@ ft-hands detects a pinch per hand (`track/pinch.h`) and publishes it to the gest
## Pinches and grips in the pointer ## Pinches and grips in the pointer
With `POINTER_HANDS=1`, the pointer helper (`pointer/helper/ft-pointer.cpp`) reads the gestures file every frame. It's off by default. With `POINTER_HANDS=1`, the pointer helper (`pointer/helper/pointer_holds.cpp`) reads the gestures file every frame. It's off by default.
- **Pinch to click.** In gaze mode a pinch works like the mouse's press: the pointer stops where the gaze put it, and the click comes when the pinch opens, where the pointer is then. A quick tap clicks where you looked. Held, the pinching hand moves the pointer to correct the gaze, and the correction is a lesson for the gaze tracker, as with the mouse. - **Pinch to click.** In gaze mode a pinch works like the mouse's press: the pointer stops where the gaze put it, and the click comes when the pinch opens, where the pointer is then. A quick tap clicks where you looked. Held, the pinching hand moves the pointer to correct the gaze, and the correction is a lesson for the gaze tracker, as with the mouse.
- **Without gaze mode,** a pinch is a real press, like the mouse's button: pressed when it closes, released when it opens, and while it's held the hand drags the pointer. A tap is still a click where the pointer is. - **Without gaze mode,** a pinch is a real press, like the mouse's button: pressed when it closes, released when it opens, and while it's held the hand drags the pointer. A tap is still a click where the pointer is.
+1 -1
View File
@@ -5,5 +5,5 @@ root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
"$root/scripts/sync.sh" >/dev/null "$root/scripts/sync.sh" >/dev/null
exec "$root/scripts/frame.sh" -C pointer/helper 'set -e; mkdir -p build exec "$root/scripts/frame.sh" -C pointer/helper 'set -e; mkdir -p build
g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers -I../common \ g++ -std=c++17 -O2 -Wall -Wno-unused-parameter -I/opt/steamvr/tools/hellovr_vulkan_linux/src/openvr/headers -I../common \
-o build/ft-pointer ft-pointer.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lpthread -o build/ft-pointer ft-pointer.cpp pointer_*.cpp -L/opt/steamvr/bin/linuxarm64 -lopenvr_api -Wl,-rpath,/opt/steamvr/bin/linuxarm64 -lpthread
echo "built build/ft-pointer"' echo "built build/ft-pointer"'
File diff suppressed because it is too large. Load diff
+478
View File
@@ -0,0 +1,478 @@
// ft-pointer's shared code: helpers, the Frame, and the Pointer struct, whose member functions are
// in ft-pointer.cpp and pointer_*.cpp.
// "The top" in comments here is the header comment of ft-pointer.cpp.
#pragma once
#include <openvr.h>
#include "pointer_config.h"
#include "vrbuttons.h"
#include "vrmath.h"
extern "C" {
#include "../../hands/include/fh_gestures.h"
}
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <deque>
#include <fstream>
#include <map>
#include <mutex>
#include <string>
#include <thread>
#include <tuple>
#include <vector>
#include <climits>
#include <fcntl.h>
#include <fnmatch.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <unistd.h>
using namespace md;
// Hand gestures from ft-hands (see "Hands" at the top): /run/user/UID/frametop-hands/gestures,
// mapped read-only. Version 1 files have pinches only; their grips read as zero.
class HandGestures {
public:
~HandGestures() { Close(); }
// A consistent copy of the file (its sequence lock), if it's there. Opens it, and
// checks it's still the same file, at most once a second.
bool Read(fh_gestures_t &out) {
const auto now = std::chrono::steady_clock::now();
if (now - checked_ > std::chrono::seconds(1)) {
checked_ = now;
const std::string path = "/run/user/" + std::to_string(getuid()) + "/frametop-hands/gestures";
struct stat st;
if (stat(path.c_str(), &st) != 0 || size_t(st.st_size) != len_ || st.st_ino != ino_) {
Close();
const int fd = open(path.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
if (fd >= 0 && fstat(fd, &st) == 0 && size_t(st.st_size) >= offsetof(fh_gestures_t, grip)) {
void *m = mmap(nullptr, size_t(st.st_size), PROT_READ, MAP_SHARED, fd, 0);
if (m != MAP_FAILED) map_ = m, len_ = size_t(st.st_size), ino_ = st.st_ino, ++opens;
}
if (fd >= 0) close(fd);
}
}
if (!map_) return false;
const auto *g = static_cast<const fh_gestures_t *>(map_);
if (std::memcmp(g->magic, FH_GESTURES_MAGIC, 8) != 0) return false;
const size_t n = std::min(len_, sizeof out);
for (int tries = 0; tries < 3; ++tries) {
const uint64_t seq = __atomic_load_n(&g->seq, __ATOMIC_ACQUIRE);
if (seq & 1) continue;
std::memset(&out, 0, sizeof out);
std::memcpy(&out, map_, n);
__atomic_thread_fence(__ATOMIC_ACQUIRE);
if (__atomic_load_n(&g->seq, __ATOMIC_RELAXED) != seq) continue;
if (out.version < 2) std::memset(out.grip, 0, sizeof out.grip);
return true;
}
return false;
}
int opens = 0; // a new file: the counters start over
private:
void Close() {
if (map_) munmap(map_, len_);
map_ = nullptr, len_ = 0, ino_ = 0;
}
void *map_ = nullptr;
size_t len_ = 0;
ino_t ino_ = 0;
std::chrono::steady_clock::time_point checked_{};
};
// The HMD's recent poses (standing universe), to turn the gestures' head-frame points into
// the room as the head was when the cameras took them.
class PoseHistory {
public:
void Add(std::chrono::steady_clock::time_point t, const vr::HmdMatrix34_t &m) {
poses_.push_back({t, m});
while (poses_.size() > 128) poses_.pop_front(); // about a second
}
// The pose at CLOCK_MONOTONIC time t_ns (steady_clock's), or the nearest kept.
bool At(uint64_t t_ns, vr::HmdMatrix34_t &out) const {
if (poses_.empty()) return false;
const std::chrono::steady_clock::time_point t{std::chrono::nanoseconds(t_ns)};
const auto *best = &poses_.front();
for (const auto &p : poses_)
if (std::chrono::abs(p.first - t) < std::chrono::abs(best->first - t)) best = &p;
out = best->second;
return true;
}
private:
std::deque<std::pair<std::chrono::steady_clock::time_point, vr::HmdMatrix34_t>> poses_;
};
// One of ft-screens' panels showing a desktop: a screen (frametop.screen.N), a floating
// window (frametop.float.N), or a floating window's popup (frametop.float.N.sub.K), not a
// control of theirs.
inline bool FramePanel(const std::string &key) {
for (const char *prefix : {"frametop.screen.", "frametop.float."}) {
if (key.rfind(prefix, 0) != 0) continue;
const std::string rest = key.substr(std::strlen(prefix));
const size_t dot = rest.find('.');
return dot == std::string::npos || rest.compare(dot, 5, ".sub.") == 0;
}
return false;
}
inline void SendTo(int fd, const char *name, const std::string &msg) {
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
std::memcpy(addr.sun_path + 1, name, std::strlen(name));
const socklen_t len = offsetof(sockaddr_un, sun_path) + 1 + std::strlen(name);
sendto(fd, msg.data(), msg.size(), 0, reinterpret_cast<sockaddr *>(&addr), len);
}
// JSON string literal (names come from other apps).
inline std::string JsonQuote(const std::string &s) {
std::string out = "\"";
for (const unsigned char c : s) {
if (c == '"' || c == '\\') out += '\\', out += char(c);
else if (c < 0x20) {
char esc[8];
std::snprintf(esc, sizeof esc, "\\u%04x", c);
out += esc;
} else out += char(c);
}
return out + "\"";
}
// Overlay keys, refreshed in the background from `vrcmd --overlays` (OpenVR has no
// public call to enumerate other apps' overlays). Hidden ones are listed too: the
// window controls under a floating panel only appear while something hovers the
// panel, and the cursor has to find them the moment they do, not a second later.
// Paused while the pointer is off: each vrcmd run connects to SteamVR as a new app, and a new
// app every second kept SteamVR (and the headset's displays) from going to standby.
// "overlays" requests (Frametop Input Settings' Ignored panels page) refresh the list even
// while paused, and are answered from this thread once it's fresh.
class OverlayList {
public:
void Start() {
out_ = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0);
thread_ = std::thread([this] {
while (running_) {
if (!paused_ || requested_) Refresh();
// Wait a second, or less when the pointer wakes (refresh right away then).
for (int i = 0; i < 10 && running_; ++i) {
const bool wasPaused = paused_;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if ((wasPaused && !paused_) || requested_) break;
}
}
});
}
void SetPaused(bool paused) { paused_ = paused; }
void Stop() {
running_ = false;
if (thread_.joinable()) thread_.join();
}
std::vector<std::string> Keys() {
std::lock_guard<std::mutex> guard(lock_);
std::vector<std::string> keys;
for (const auto &e : entries_) keys.push_back(e.key);
return keys;
}
// Answer `to` with {"t":"overlays","list":[{"key","name","visible"}...]} after the next refresh.
void Request(const sockaddr_un &to, socklen_t len) {
if (len <= offsetof(sockaddr_un, sun_path)) return;
std::lock_guard<std::mutex> guard(lock_);
if (waiting_.size() < 8) waiting_.push_back({to, len});
requested_ = true;
}
private:
struct Entry {
std::string key, name;
bool visible;
};
void Refresh() {
requested_ = false;
FILE *p = popen("LD_LIBRARY_PATH=/opt/steamvr/bin/linuxarm64 /opt/steamvr/bin/linuxarm64/vrcmd --overlays 2>/dev/null", "r");
if (!p) return;
std::vector<Entry> entries;
char line[1024];
while (std::fgets(line, sizeof line, p)) {
// 'key' -- 'name', WxH visible VROverlayType_...
if (line[0] != '\'') continue;
const char *end = std::strchr(line + 1, '\'');
if (!end) continue;
const std::string key(line + 1, size_t(end - (line + 1)));
const std::string rest(end);
if (rest.find("Thumbnail") != std::string::npos || rest.find("Subview") != std::string::npos) continue;
if (key.rfind("system.pointer", 0) == 0 || key.rfind("system.cursor", 0) == 0 ||
key.rfind("frametop.pointer", 0) == 0 || key.rfind("frametop.guide", 0) == 0 ||
key == "frametop.gazepanel" || // the gaze calibration panel, fixed to the headset
key == "frametop.catcher" || // ft-screens' release catcher: only on a laser mid-drag
key == "system.HeadsetView" || key == "system.toast")
continue;
// The name can hold quotes; it ends at the last "', " (the size and state follow).
const auto nameAt = rest.find("-- '"), nameEnd = rest.rfind("', ");
const std::string name =
nameAt != std::string::npos && nameEnd > nameAt + 3 ? rest.substr(nameAt + 4, nameEnd - nameAt - 4) : key;
entries.push_back({key, name, rest.find(" not_visible ") == std::string::npos});
}
pclose(p);
std::vector<std::pair<sockaddr_un, socklen_t>> waiting;
{
std::lock_guard<std::mutex> guard(lock_);
entries_ = std::move(entries);
waiting.swap(waiting_);
}
if (waiting.empty()) return;
std::string msg = "{\"t\":\"overlays\",\"list\":[";
for (size_t i = 0; i < entries_.size(); ++i)
msg += std::string(i ? "," : "") + "{\"key\":" + JsonQuote(entries_[i].key) + ",\"name\":" +
JsonQuote(entries_[i].name) + ",\"visible\":" + (entries_[i].visible ? "true" : "false") + "}";
msg += "]}";
for (const auto &[to, len] : waiting)
sendto(out_, msg.data(), msg.size(), MSG_DONTWAIT, reinterpret_cast<const sockaddr *>(&to), len);
}
std::thread thread_;
int out_ = -1;
std::atomic<bool> paused_{false};
std::atomic<bool> running_{true};
std::atomic<bool> requested_{false};
std::mutex lock_;
std::vector<Entry> entries_; // written only by the thread; the lock guards readers
std::vector<std::pair<sockaddr_un, socklen_t>> waiting_;
};
using Clock = std::chrono::steady_clock;
// One frame's poses and time: ReadFrame reads them after the relay's commands, and the sections
// after it share them. handOk: PinchesAndGrips read the hand gestures (for HandsStopped).
struct Frame {
vr::TrackedDevicePose_t all[vr::k_unMaxTrackedDeviceCount];
vr::TrackedDevicePose_t hmdRaw;
Clock::time_point tnow;
Vec3 eye;
bool handOk = false;
const vr::TrackedDevicePose_t &Hmd() const { return all[0]; }
};
// Everything the pointer keeps: its settings, what Init sets up, and the state the main loop
// carries from one frame to the next.
struct Pointer {
PointerConfig cfg;
// Head follow (see the top). followConf is POINTER_FOLLOW as last read: a reload only
// overrides a "follow" command when the setting itself changed.
bool follow = false, followConf = false, followReset = true;
// Gaze mode (see the top); gazeConf is POINTER_GAZE as last read, like followConf.
bool gazeOn = false, gazeConf = false;
// Set in Init, after VR_Init.
vr::IVRSystem *sys = nullptr;
vr::IVROverlay *overlay = nullptr;
// The dot, the marker, and laser mode's overlay, created in Init.
vr::VROverlayHandle_t cursor = vr::k_ulOverlayHandleInvalid;
vr::VROverlayHandle_t marker = vr::k_ulOverlayHandleInvalid;
vr::VROverlayHandle_t laserMode = vr::k_ulOverlayHandleInvalid;
bool laserModeShown = false;
int in = -1, out = -1; // @ft_pointer_helper, and the socket we send from (opened in Init)
ControllerButtons controllerButtons; // Frame controller buttons (vrbuttons.h)
OverlayList overlays;
std::map<std::string, vr::VROverlayHandle_t> handles;
std::map<std::string, bool> sceneGraph; // no texture: plane test instead of ComputeOverlayIntersection
std::map<std::string, bool> visible; // refreshed every 50 ms
Clock::time_point lastVisible{}; // set in Init
// The plane of the last panel the cursor was on, and the last point on it (panel edges).
Vec3 edgePoint, edgeNormal, edgeLast;
std::string edgeKey;
bool active = false, recenter = false, anchored = false;
Clock::time_point lastMouse{}, claimAt{}, claimRelease{}, wokeAt{}, noWakeUntil{};
bool claimPending = false, claimHeld = false;
// Last used wins: since when each controller has been moving (zero: it isn't).
Clock::time_point movingSince[vr::k_unMaxTrackedDeviceCount] = {};
// Tilt mode (see top of file).
bool leftHeld = false, tilting = false, tiltStart = false, swallowedRight = false;
// The mouse's buttons in gaze mode (see the top): leftDown, rightDown as the relay last said;
// aimRight, the held-back press is the right button's; chordDrag, a drag the right button
// began during the left's held-back press (the first release drops it, the other's is nothing).
bool leftDown = false, rightDown = false, aimRight = false, chordDrag = false;
bool ignoreLeftUp = false;
// The keyboard clicks' keys as the relay last said, and a tilt gaze_right holds during a
// keyboard drag (see the top): the head turns the panel, from where it was (keyTiltYaw, keyTiltPitch).
bool keyLeftDown = false, keyRightDown = false, keyTilting = false;
double keyTiltYaw = 0, keyTiltPitch = 0;
double tiltYaw = 0, tiltPitch = 0;
double dragDistance = 0, lastDistance = 1.5; // drag lock: distance from the anchor at the press
bool onVrSettings = false; // the cursor is on the SteamVR Settings page (kept while dragging)
bool catcherHidesHit = false; // the laser-catching dot hides SteamVR's hit dot (Settings page)
Clock::time_point dropHoldUntil{}; // after a left release: keep the drag pose this long
bool debug = false;
std::string lastHit;
// The ft-screens panel the left button was pressed on, and where it was then (see the top).
std::string pressKey;
vr::HmdMatrix34_t pressPose{};
Clock::time_point lastDebug{}; // set in Init
vr::VROverlayHandle_t systemPointer = vr::k_ulOverlayHandleInvalid; // found in Init
Vec3 pivot, tiltOrigin, lastPoint, lastOrigin, lastAim{0, 0, -1};
Basis tiltBasis{};
bool headsetOff = false; // nobody is wearing the headset (see the main loop)
Vec3 anchor;
double yaw = 0, pitch = 0;
Vec3 followRef{0, 0, -1}; // head follow's reference direction (see the top)
Clock::time_point followAt{}; // its last update, for the easing (set in Init)
bool following = false; // past the leash: easing toward the head
double followLag = 0; // radians the reference trails the head
std::chrono::steady_clock::time_point leashOutSince{}; // head past the leash since (delay)
// Gaze mode (see the top).
struct Gaze {
double hy = 0, hp = 0, rhy = 0, rhp = 0; // corrected, and raw
Clock::time_point at{};
} gz;
bool gazeOwns = true; // the pointer follows the gaze; false: the mouse has it
bool nudging = false; // the mouse took it from the gaze: the next click may be a lesson
double nudgeRawHy = 0, nudgeRawHp = 0, nudgeMoved = 0;
vr::HmdMatrix34_t nudgeHead{}, lastHead{};
bool haveHead = false, havePoint = false;
Clock::time_point nudgeAt{}, retakeSince{};
// A held-back press (see the top): aimHeld while the button is down; then the click
// (clickPress at the end of the next frame, clickRelease 40 ms later). gazeBack: give
// the gaze the pointer again when the press or click is over.
vr::TrackedDeviceIndex_t ours = vr::k_unTrackedDeviceIndexInvalid;
bool aimHeld = false, clickPress = false, clickRelease = false, gazeBack = false;
bool aimHand = false; // the held-back press is a hold's (below): it never turns into a real press
Clock::time_point aimSince{}, clickReleaseAt{};
// Holds (see "Gaze precision" and "Hands" at the top): a pinch or grip, or a gaze
// precision or gaze drag button, held now. What steers the pointer meanwhile (a hand, or
// the mouse through its own moves), from where it pointed when the hold began (for a
// hand: seen from the eye then, in the room), and the pointer then.
HandGestures handFile;
PoseHistory poses;
enum class Src { None, Hand, Mouse, Head };
struct Hold {
Src src = Src::None;
int side = -1; // a hand's side (0 left, 1 right)
bool grip = false; // pressed at once and dragging (a grip, gaze drag), not a click on release
bool engaged = false; // past the dead zone
bool pressed = false; // a real press went out (a grip or gaze drag, or a pinch without gaze mode)
bool promote = false; // held still for POINTER_GAZE_HOLD, it becomes a real press (keyboard clicks)
bool right = false; // the right button (gaze_right)
bool keyDrag = false; // gaze_right pressed while gaze_left aimed: a left press, either key ends it
double pressYaw = 0, pressPitch = 0; // the pointer at the press (a quick tap clicks there)
Vec3 origin;
double refYaw = 0, refPitch = 0, startYaw = 0, startPitch = 0, lastYaw = 0, lastPitch = 0;
} hold;
// "precision|gazedrag <source> 1|0" from the relay, done in the frame (see Holds).
struct DevicePress {
std::string source;
bool drag, down;
};
std::vector<DevicePress> devicePresses;
// "gazekey left|right 1|0" from the relay (keyboard clicks), done in the frame.
struct KeyPress {
bool right, down;
};
std::vector<KeyPress> keyPresses;
uint32_t seenBegins[2][2] = {}, seenEnds[2][2] = {}; // [pinch, grip][side], as last read
bool handBaseline = false;
int handOpens = 0;
uint64_t handSeq = 0, handPublished = 0;
Clock::time_point handUsed{}; // a gesture began then (keeps the pointer, like gaze mode)
Clock::time_point lastTyping{}; // the relay's last "typing": a key on a keyboard
// Gaze mode outside games, and its dot (see the top): lastMove/lastHeld/pulseAt.
bool inGame = false, gazeAwake = false;
Clock::time_point inGameAt{}, gazeAwakeAt{};
Clock::time_point lastMove{}, lastHeld{}, pulseAt{};
// The left button, as sent to the driver; pressRight: the next press is the right button
// instead (gaze_right), heldButton: the one pressed.
bool pressRight = false;
std::string heldButton = "trigger";
bool confirmLesson = false; // a keyboard click's quick tap: a lesson with no correction (see the top)
// The gaze calibration panel is up until then ("calpanel 1"; see the top); calOpened: it just
// came up, so a press in progress ends without a click.
Clock::time_point calPanelUntil{};
bool calOpened = false;
Clock::time_point lastSlow{}; // set in Init
void ApplyConfig();
void Init();
void Wake(Clock::time_point t);
void PressLeft();
void ReleaseLeft();
bool CanAim();
void AimStart(bool right);
void ChordDrop();
void LeftButton(bool down);
bool RightButton(bool down);
bool MouseMoveHeld();
void SendPose(Vec3 originStanding, const Basis &b);
std::pair<bool, Vec3> HeadPos();
void Borrow();
void GiveBack();
std::string FindPanel(const char *key, Panel &p, Vec3 &eye);
void GrabProbe(const char *key);
std::string Place(const char *key, Vec3 target, const Basis &bt, double grabBelow);
// Nearest's answer: the nearest overlay along a ray.
struct Hit {
double along = 1e9;
std::string key;
bool scene = false;
Vec3 point, normal;
};
// Where the cursor is this frame (FindCursor), for DrawDot and SendRay.
struct Spot {
bool dragging = false;
Vec3 dir, point;
double best = 1e9, distance = 0;
bool onEdge = false, occluded = false, onScene = false, onPanel = false;
};
// The main loop's sections, in the order main calls them, and what they share.
void HeadsetOff();
void GazeAwake();
void RelayCommands();
void Command(const char *buf, const sockaddr_un &sender, socklen_t senderLen);
void ReadFrame(Frame &frame);
void ClaimPulse(const Frame &frame);
void PauseOverlayList();
void LaserMode();
void HandRole(const Frame &frame);
void LastUsedWins(const Frame &frame);
void Recenter(const Frame &frame);
void GazeMode(const Frame &frame);
void Hands(const Frame &frame);
bool HandAngles(const float p[3], uint64_t t_ns, const Vec3 &from, double &hy, double &hp);
void EndHold(bool lost);
void StartHold(bool press, Clock::time_point tnow);
void Steer(double hy, double hp, double deadzone, double gain, Clock::time_point tnow);
void BeginHold(int side, bool grip, const fh_pinch_t &g, Clock::time_point tnow);
void GazePrecisionButtons(const Frame &frame);
bool HeadAngles(const vr::TrackedDevicePose_t &hmd, double &hy, double &hp);
void KeyboardClicks(const Frame &frame);
void CalPanelOpened();
void HeadSteer(const Frame &frame);
void PinchesAndGrips(Frame &frame);
void HandsStopped(const Frame &frame);
void MarkHeld(const Frame &frame);
void HeadFollow(const Frame &frame);
void SlowWork();
void Cursor(const Frame &frame);
void Tilt(const Frame &frame);
Hit Nearest(Vec3 from, Vec3 d);
Spot FindCursor(const Frame &frame);
void DrawDot(const Frame &frame, const Spot &spot);
void SendRay(const Frame &frame, const Spot &spot);
void HeldBackPress(const Frame &frame);
void KeyboardClickHold(const Frame &frame);
void Click(const Frame &frame);
void PollControllerButtons();
bool SteamVRQuit();
};
+114
View File
@@ -0,0 +1,114 @@
// ft-pointer's settings, from ~/.config/frametop.conf. "The top" in comments here is the
// header comment of ft-pointer.cpp, which describes every setting.
#pragma once
#include <algorithm>
#include <cstdlib>
#include <fstream>
#include <map>
#include <string>
#include <vector>
inline std::map<std::string, std::string> ReadConfig() {
std::map<std::string, std::string> conf;
const char *home = std::getenv("HOME");
std::ifstream in(std::string(home ? home : "") + "/.config/frametop.conf");
std::string line;
while (std::getline(in, line)) {
line = line.substr(0, line.find('#'));
const auto eq = line.find('=');
if (eq == std::string::npos) continue;
auto trim = [](std::string s) {
s.erase(0, s.find_first_not_of(" \t"));
s.erase(s.find_last_not_of(" \t") + 1);
return s;
};
conf[trim(line.substr(0, eq))] = trim(line.substr(eq + 1));
}
return conf;
}
inline double ConfDouble(const std::map<std::string, std::string> &c, const char *key, double fallback) {
auto it = c.find(key);
return it == c.end() ? fallback : std::atof(it->second.c_str());
}
// POINTER_IGNORE: overlay keys the pointer passes through, as if they weren't there
// (display-only panels such as a performance overlay). Comma-separated shell patterns
// (fnmatch, no escapes), so "vendor.app*" covers an app's overlays.
inline std::vector<std::string> ParseIgnore(const std::string &list) {
std::vector<std::string> out;
size_t at = 0;
while (at <= list.size()) {
const size_t comma = std::min(list.find(',', at), list.size());
std::string item = list.substr(at, comma - at);
item.erase(0, item.find_first_not_of(" \t"));
item.erase(item.find_last_not_of(" \t") + 1);
if (!item.empty()) out.push_back(item);
at = comma + 1;
}
return out;
}
struct PointerConfig {
double freeDistance = 1.5, cursorDeg = 0.4, originFraction = 0.95, originMargin = 0.15, sceneRadius = 0.5,
edgeReach = 0.3, grabOffset = 0.075,
slideSpeed = 0.5, leashDeg = 10, leashReturn = 0.2, leashDelay = 0.2, followReach = 70;
// POINTER_FOLLOW and POINTER_GAZE as read. They turn head follow and gaze mode on or off
// only when they change, so a "follow" or "gaze" command wins until then (ApplyConfig in
// ft-pointer.cpp).
bool follow = false, gaze = false;
double gazeRetake = 5, gazeNudgeMax = 55, gazeHold = 0.5, gazeShow = 1;
bool gazeDotAlways = true; // POINTER_GAZE_DOT (see the top)
double headDeadzone = 0.5, keyTap = 0.25; // POINTER_HEAD_DEADZONE, POINTER_KEY_TAP (keyboard clicks, see the top)
// Hands (see the top): POINTER_HANDS, POINTER_PINCH_GAIN, POINTER_PINCH_DEADZONE, POINTER_GRIP_GAIN.
bool handsOn = false;
double pinchGain = 0.5, pinchDeadzone = 1.5, gripGain = 1.0, handBelow = 0.35, typingHold = 1.0;
// Gaze precision (see the top): POINTER_GAZE_MOUSE (precision: the mouse's left button
// holds back its press in gaze mode; direct: it clicks at once), POINTER_ROLE.
bool gazeMousePrecision = true;
bool gazeMouseHeld = true; // POINTER_GAZE_MOUSE_MOVE (see the top)
std::string role = "right";
double pickupScale = 1; // POINTER_CONTROLLER_PICKUP: scales the controller-moved limits
std::vector<std::string> ignore; // POINTER_IGNORE (see ParseIgnore)
void Load(const std::map<std::string, std::string> &conf) {
freeDistance = std::clamp(ConfDouble(conf, "POINTER_DISTANCE", 1.5), 0.3, 10.0);
cursorDeg = std::clamp(ConfDouble(conf, "POINTER_CURSOR_DEG", 0.4), 0.05, 5.0);
originFraction = std::clamp(ConfDouble(conf, "POINTER_ORIGIN_FRACTION", 0.95), 0.0, 0.98);
originMargin = std::clamp(ConfDouble(conf, "POINTER_ORIGIN_MARGIN", 0.15), 0.0, 1.0);
sceneRadius = std::clamp(ConfDouble(conf, "POINTER_SCENE_RADIUS", 0.5), 0.05, 2.0);
edgeReach = std::clamp(ConfDouble(conf, "POINTER_EDGE_REACH", 0.3), 0.0, 2.0);
grabOffset = std::clamp(ConfDouble(conf, "LAYOUT_GRAB_OFFSET", 0.075), 0.0, 1.0);
slideSpeed = std::clamp(ConfDouble(conf, "LAYOUT_SLIDE_SPEED", 0.5), 0.02, 2.0);
leashDeg = std::clamp(ConfDouble(conf, "POINTER_LEASH_DEG", 10), 0.0, 90.0);
leashReturn = std::clamp(ConfDouble(conf, "POINTER_LEASH_RETURN", 0.2), 0.0, 5.0);
leashDelay = std::clamp(ConfDouble(conf, "POINTER_LEASH_DELAY", 0.2), 0.0, 5.0);
followReach = std::clamp(ConfDouble(conf, "POINTER_FOLLOW_REACH", 70), 10.0, 89.0);
follow = ConfDouble(conf, "POINTER_FOLLOW", 0) != 0;
gazeRetake = std::clamp(ConfDouble(conf, "POINTER_GAZE_RETAKE", 5), 1.0, 45.0);
gazeNudgeMax = std::clamp(ConfDouble(conf, "POINTER_GAZE_NUDGE_MAX", 55), 1.0, 110.0);
gazeHold = std::clamp(ConfDouble(conf, "POINTER_GAZE_HOLD", 0.5), 0.1, 5.0);
gazeShow = std::clamp(ConfDouble(conf, "POINTER_GAZE_SHOW", 1), 0.0, 30.0);
headDeadzone = std::clamp(ConfDouble(conf, "POINTER_HEAD_DEADZONE", 0.5), 0.0, 5.0);
keyTap = std::clamp(ConfDouble(conf, "POINTER_KEY_TAP", 0.25), 0.0, 1.0);
const auto gd = conf.find("POINTER_GAZE_DOT");
gazeDotAlways = gd == conf.end() || gd->second != "moving";
gaze = ConfDouble(conf, "POINTER_GAZE", 0) != 0;
handsOn = ConfDouble(conf, "POINTER_HANDS", 0) != 0;
pinchGain = std::clamp(ConfDouble(conf, "POINTER_PINCH_GAIN", 0.5), 0.05, 3.0);
pinchDeadzone = std::clamp(ConfDouble(conf, "POINTER_PINCH_DEADZONE", 1.5), 0.0, 10.0);
gripGain = std::clamp(ConfDouble(conf, "POINTER_GRIP_GAIN", 1.0), 0.05, 3.0);
handBelow = std::clamp(ConfDouble(conf, "POINTER_GRIP_BELOW", 0.35), 0.05, 1.0);
typingHold = std::clamp(ConfDouble(conf, "POINTER_PINCH_TYPING", 1.0), 0.0, 5.0);
const auto gm = conf.find("POINTER_GAZE_MOUSE");
gazeMousePrecision = gm == conf.end() || gm->second != "direct";
const auto mm = conf.find("POINTER_GAZE_MOUSE_MOVE");
gazeMouseHeld = mm == conf.end() || mm->second != "free";
const auto ro = conf.find("POINTER_ROLE");
role = ro != conf.end() && (ro->second == "left" || ro->second == "stylus") ? ro->second : "right";
pickupScale = std::clamp(ConfDouble(conf, "POINTER_CONTROLLER_PICKUP", 1), 0.5, 5.0);
const auto ig = conf.find("POINTER_IGNORE");
ignore = ParseIgnore(ig == conf.end() ? "" : ig->second);
}
};
+474
View File
@@ -0,0 +1,474 @@
// ft-pointer: recenter, head follow, the overlay handles, and the cursor (see "Cursor model" at
// the top).
// "The top" in comments here is the header comment of ft-pointer.cpp.
#include "pointer.h"
namespace {
bool Ignored(const std::vector<std::string> &patterns, const std::string &key) {
for (const auto &p : patterns)
if (fnmatch(p.c_str(), key.c_str(), FNM_NOESCAPE) == 0) return true;
return false;
}
vr::HmdMatrix34_t Billboard(Vec3 at, Vec3 eye) {
// Overlay faces +Z; point +Z at the eye, keep +Y roughly up.
const Vec3 z = Normalize(eye - at);
const Vec3 x = Normalize(Cross({0, 1, 0}, z));
const Vec3 y = Cross(z, x);
vr::HmdMatrix34_t m{};
const Vec3 cols[3] = {x, y, z};
for (int c = 0; c < 3; ++c) {
m.m[0][c] = float(cols[c].x);
m.m[1][c] = float(cols[c].y);
m.m[2][c] = float(cols[c].z);
}
m.m[0][3] = float(at.x);
m.m[1][3] = float(at.y);
m.m[2][3] = float(at.z);
return m;
}
// Unit vector v, turned toward unit vector `center` until it's at most maxRad from it.
Vec3 PullWithin(Vec3 v, Vec3 center, double maxRad) {
if (std::acos(std::clamp(Dot(v, center), -1.0, 1.0)) <= maxRad) return v;
Vec3 axis = Cross(center, v);
if (Length(axis) < 1e-9) axis = Cross(center, {0, 1, 0}); // opposite: any perpendicular
return RotateAbout(center, Normalize(axis), maxRad);
}
// Unit direction with its pitch limited to +-maxDeg (AimBasis needs it off vertical).
Vec3 LimitPitch(Vec3 d, double maxDeg) {
const double pitch = std::clamp(std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI, -maxDeg, maxDeg);
return Direction(std::atan2(-d.x, -d.z) * 180 / M_PI, pitch);
}
// SteamVR Settings page (see the top): the laser starts this far from the eye, the dot is
// drawn this far out, and the laser-catching dot sits this far out (metres).
constexpr double SETTINGS_ORIGIN = 0.25, SETTINGS_DOT = 0.6, SETTINGS_CATCHER = 8.0;
// Whether the line of sight from `eye` along `d` crosses the SteamVR Settings page, drawn by
// the dashboard's scene-graph panel (valve.steam.gamepadui.frame.menu.N, transform t); see
// "SteamVR Settings" at the top. The page has no size in OpenVR, so this is its area as
// measured on the Frame, generously: in metres from the panel's origin, which is near the
// page's left edge, it ran from about -0.35 (the sidebar) to 1.15 across and +-0.4 up and
// down, with the transform scaled 0.369. Kept in the transform's units so it scales with it.
bool OnSettingsPage(const vr::HmdMatrix34_t &t, Vec3 eye, Vec3 d) {
const Vec3 c = Position(t), x{t.m[0][0], t.m[1][0], t.m[2][0]}, y{t.m[0][1], t.m[1][1], t.m[2][1]},
z{t.m[0][2], t.m[1][2], t.m[2][2]};
const double sx = Dot(x, x), sy = Dot(y, y), denom = Dot(d, z);
if (sx < 1e-9 || sy < 1e-9 || std::fabs(denom) < 1e-6) return false;
const double along = Dot(c - eye, z) / denom;
if (along <= 0) return false;
const Vec3 off = eye + d * along - c;
const double u = Dot(off, x) / sx, v = Dot(off, y) / sy; // in the transform's units
return u >= -1.35 && u <= 3.4 && std::fabs(v) <= 1.25;
}
} // namespace
// A pending recenter (the command, a wake, a first move): the anchor goes to the eye, aimed where the
// head faces.
void Pointer::Recenter(const Frame &frame) {
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
const auto &hm = hmd.mDeviceToAbsoluteTracking.m;
const Vec3 &eye = frame.eye;
if (recenter && hmd.bPoseIsValid) {
anchor = eye;
const Vec3 f{-hm[0][2], -hm[1][2], -hm[2][2]};
yaw = std::atan2(-f.x, -f.z) * 180 / M_PI;
pitch = std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI;
anchored = true;
recenter = false;
followReset = true;
}
}
// Head follow (see the top): past the leash (for the delay), ease the reference to the
// head's facing, and turn the cursor with it. Not in gaze mode: the gaze places it.
void Pointer::HeadFollow(const Frame &frame) {
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
const auto &hm = hmd.mDeviceToAbsoluteTracking.m;
const auto tnow = frame.tnow;
const Vec3 &eye = frame.eye;
if (follow && !gazeOn && active && anchored && hmd.bPoseIsValid) {
const Vec3 head = LimitPitch(Vec3{-hm[0][2], -hm[1][2], -hm[2][2]}, 85);
if (followReset) {
followRef = head, followLag = 0, leashOutSince = {};
followReset = following = false;
}
const double dt = std::min(0.1, std::chrono::duration<double>(tnow - followAt).count());
const double leash = cfg.leashDeg * M_PI / 180;
const double lag = std::acos(std::clamp(Dot(followRef, head), -1.0, 1.0));
double keep = lag; // how far the reference stays behind the head after this frame
if (leash <= 0) {
keep = 0; // head-locked
} else if (following) {
keep = lag * (cfg.leashReturn > 0 ? std::exp(-dt / cfg.leashReturn) : 0.0);
// A fast turn drags it at the leash's end; past it already (after the delay), it
// can't fall further behind, and it closes in from there without a jump.
keep = std::min(keep, std::max(leash, followLag));
if (keep < 0.05 * M_PI / 180) keep = 0, following = false; // landed on the facing
} else if (lag > leash) {
if (leashOutSince == decltype(leashOutSince){}) leashOutSince = tnow;
if (std::chrono::duration<double>(tnow - leashOutSince).count() >= cfg.leashDelay)
following = true, leashOutSince = {};
} else {
leashOutSince = {}; // back inside before the delay: a glance
}
followLag = keep;
const Vec3 ref = LimitPitch(PullWithin(followRef, head, keep), 85);
if (!leftHeld && tnow >= dropHoldUntil) {
// The cursor keeps its offset from the reference (frames without roll).
Vec3 d = FromBasis(AimBasis(ref), ToBasis(AimBasis(followRef), Direction(yaw, pitch)));
d = PullWithin(Normalize(d), ref, cfg.followReach * M_PI / 180);
yaw = std::atan2(-d.x, -d.z) * 180 / M_PI;
pitch = std::clamp(std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI, -85.0, 85.0);
// The ray origin closes in on the eye as the reference does on the facing.
anchor = eye + (anchor - eye) * (leash <= 0 ? 0.0 : lag > 1e-6 ? keep / lag : following ? 0.0 : 1.0);
}
followRef = ref;
}
followAt = tnow;
}
// Slow work, once a second: overlay handles, our device index, laser width.
void Pointer::SlowWork() {
const auto now = std::chrono::steady_clock::now();
if (now - lastSlow > std::chrono::seconds(1)) {
lastSlow = now;
handles.clear();
for (const auto &key : overlays.Keys()) {
if (Ignored(cfg.ignore, key)) continue;
vr::VROverlayHandle_t h;
if (overlay->FindOverlay(key.c_str(), &h) != vr::VROverlayError_None) continue;
handles[key] = h;
// Scene-graph overlays are placed absolutely. Other overlays can report no
// texture too (gamescope's app panels, placed as dashboard tabs, share theirs
// from another process), and ComputeOverlayIntersection handles those.
uint32_t tw = 0, th = 0;
overlay->GetOverlayTextureSize(h, &tw, &th);
vr::VROverlayTransformType tt = vr::VROverlayTransform_Invalid;
overlay->GetOverlayTransformType(h, &tt);
// ft-screens' panels (frametop.screen.N, floating windows with their popups,
// frametop.float.N..., the keyboard) are 0x0 and absolute too (a shared
// texture), but they're real panels of any size.
sceneGraph[key] = (tw == 0 || th == 0) && tt == vr::VROverlayTransform_Absolute &&
key.rfind("frametop.", 0) != 0;
}
ours = vr::k_unTrackedDeviceIndexInvalid;
for (vr::TrackedDeviceIndex_t i = 0; i < vr::k_unMaxTrackedDeviceCount; ++i) {
char type[64] = "";
sys->GetStringTrackedDeviceProperty(i, vr::Prop_ControllerType_String, type, sizeof type);
if (std::strcmp(type, "ft_pointer") == 0) ours = i;
}
}
if (now - lastVisible > std::chrono::milliseconds(50) || visible.size() != handles.size()) {
lastVisible = now;
visible.clear();
for (const auto &[key, h] : handles) visible[key] = overlay->IsOverlayVisible(h);
}
}
// The cursor: tilting a grabbed panel, or pointing (find the spot, draw the dot, send the ray).
void Pointer::Cursor(const Frame &frame) {
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
if (active && anchored && hmd.bPoseIsValid && tilting) {
Tilt(frame);
} else if (active && anchored && hmd.bPoseIsValid) {
const Spot spot = FindCursor(frame);
DrawDot(frame, spot);
SendRay(frame, spot);
}
}
void Pointer::Tilt(const Frame &frame) {
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
const Vec3 &eye = frame.eye;
const vr::TrackedDevicePose_t &hmdRaw = frame.hmdRaw;
// Rotate the device around the grab point; the grabbed panel turns with it.
if (tiltStart) {
pivot = lastPoint;
tiltOrigin = lastOrigin;
tiltBasis = AimBasis(lastAim);
tiltStart = false; // angles carry on from any earlier tilt in this drag
overlay->HideOverlay(cursor);
overlay->HideOverlay(marker);
}
const double yr = tiltYaw * M_PI / 180, pr = tiltPitch * M_PI / 180;
const Vec3 up{0, 1, 0}, side = tiltBasis.x;
auto turn = [&](Vec3 v) { return RotateAbout(RotateAbout(v, side, pr), up, yr); };
const Vec3 originStanding = pivot + turn(tiltOrigin - pivot);
const Basis b{turn(tiltBasis.x), turn(tiltBasis.y), turn(tiltBasis.z)};
const auto &S = hmd.mDeviceToAbsoluteTracking, &R = hmdRaw.mDeviceToAbsoluteTracking;
auto toRaw = [&](Vec3 v) { return Rotate(R, RotateInverse(S, v)); }; // directions: raw <- standing
const Vec3 originRaw = Position(R) + toRaw(originStanding - eye);
double q[4];
BasisQuat({toRaw(b.x), toRaw(b.y), toRaw(b.z)}, q);
char msg[200];
std::snprintf(msg, sizeof msg, "posq %.5f %.5f %.5f %.6f %.6f %.6f %.6f", originRaw.x, originRaw.y,
originRaw.z, q[0], q[1], q[2], q[3]);
SendTo(out, "ft_pointer", msg);
}
// Nearest visible overlay along a ray (frozen while dragging).
Pointer::Hit Pointer::Nearest(Vec3 from, Vec3 d) {
Hit h;
for (const auto &[key, handle] : handles) {
if (!visible[key]) continue;
if (sceneGraph[key]) {
// Plane test: overlay origin and its +Z normal, within sceneRadius of the origin.
vr::ETrackingUniverseOrigin uo;
vr::HmdMatrix34_t t{};
if (overlay->GetOverlayTransformAbsolute(handle, &uo, &t) != vr::VROverlayError_None) continue;
const Vec3 center = Position(t), normal{t.m[0][2], t.m[1][2], t.m[2][2]};
const double denom = Dot(d, normal);
if (std::fabs(denom) < 1e-4) continue;
const double along = Dot(center - from, normal) / denom;
const Vec3 at = from + d * along;
if (along > 0.05 && along < h.along && std::sqrt(Dot(at - center, at - center)) <= cfg.sceneRadius)
h.along = along, h.key = key, h.scene = true, h.point = at, h.normal = normal;
continue;
}
vr::VROverlayIntersectionParams_t params{};
params.vSource = {float(from.x), float(from.y), float(from.z)};
params.vDirection = {float(d.x), float(d.y), float(d.z)};
params.eOrigin = vr::TrackingUniverseStanding;
vr::VROverlayIntersectionResults_t r{};
if (overlay->ComputeOverlayIntersection(handle, &params, &r) && r.fDistance > 0.05f &&
r.fDistance < h.along) {
h.along = r.fDistance, h.key = key, h.scene = false;
h.point = {r.vPoint.v[0], r.vPoint.v[1], r.vPoint.v[2]};
h.normal = {r.vNormal.v[0], r.vNormal.v[1], r.vNormal.v[2]};
}
}
return h;
}
// Where the cursor is this frame: on a panel, a scene-graph plane, or a panel's edge, or out in free
// space.
Pointer::Spot Pointer::FindCursor(const Frame &frame) {
const auto tnow = frame.tnow;
const Vec3 &eye = frame.eye;
const bool dragging = leftHeld || tnow < dropHoldUntil;
if (!dragging) tiltYaw = tiltPitch = 0; // drop finished: back to plain pointing
const Vec3 dir = Direction(yaw, pitch);
Hit first;
if (!dragging) first = Nearest(anchor, dir);
double best = first.along;
std::string bestKey = first.key;
bool bestScene = first.scene;
Vec3 bestPoint = first.point, bestNormal = first.normal;
bool onEdge = false;
if (!dragging && best < 1e8 && !bestScene) {
edgeKey = bestKey, edgePoint = bestPoint, edgeNormal = Normalize(bestNormal), edgeLast = bestPoint;
} else if (!dragging && best >= 1e8 && !edgeKey.empty() && visible[edgeKey]) {
// Just off a panel: stay on its plane (see "Panel edges" at the top).
const double denom = Dot(dir, edgeNormal);
if (std::fabs(denom) > 1e-4) {
const double along = Dot(edgePoint - anchor, edgeNormal) / denom;
const Vec3 at = anchor + dir * along;
if (along > 0.05 && std::sqrt(Dot(at - edgeLast, at - edgeLast)) <= cfg.edgeReach) {
best = along;
bestKey = edgeKey;
onEdge = true;
}
}
}
// Held on one of ft-screens' panels that stays where it is: onto whichever of them
// the ray meets (see the top).
if (leftHeld && !pressKey.empty()) {
vr::ETrackingUniverseOrigin uo;
vr::HmdMatrix34_t now{};
auto it = handles.find(pressKey);
bool still = it != handles.end() &&
overlay->GetOverlayTransformAbsolute(it->second, &uo, &now) == vr::VROverlayError_None;
for (int i = 0; still && i < 3; ++i)
for (int j = 0; j < 4; ++j)
if (std::fabs(now.m[i][j] - pressPose.m[i][j]) > 0.001f) still = false;
if (still) {
Hit h;
for (const auto &[key, handle] : handles) {
if (!visible[key] || !FramePanel(key)) continue;
vr::VROverlayIntersectionParams_t params{};
params.vSource = {float(anchor.x), float(anchor.y), float(anchor.z)};
params.vDirection = {float(dir.x), float(dir.y), float(dir.z)};
params.eOrigin = vr::TrackingUniverseStanding;
vr::VROverlayIntersectionResults_t r{};
if (overlay->ComputeOverlayIntersection(handle, &params, &r) && r.fDistance > 0.05f &&
r.fDistance < h.along)
h.along = r.fDistance, h.key = key;
}
if (h.along < 1e8) dragDistance = h.along, lastHit = h.key;
} else {
pressKey.clear(); // carried: the lock holds for the rest of this press
}
}
// While dragging: keep the press-time distance and show the non-interactive marker.
// On a scene-graph plane or a panel's edge: the laser-catching dot goes 5 cm behind it.
double distance = dragging ? dragDistance : (best < 1e8 ? best : cfg.freeDistance);
Vec3 point = anchor + dir * distance;
bool occluded = false;
if (!dragging) {
// The cursor lands on what you see under it: the ray above starts at the anchor,
// not the eye, so after leaning it can pick a panel that something nearer
// covers from where you are now (panels close together in view, at different
// depths). Anything in front of the point on the eye's line of sight wins.
const double toPoint = std::sqrt(Dot(point - eye, point - eye));
const Hit front = Nearest(eye, Normalize(point - eye));
if (front.along < toPoint - 0.02) {
occluded = true;
bestKey = front.key, bestScene = front.scene;
point = front.point;
if (!front.scene) edgeKey = front.key, edgePoint = front.point, edgeNormal = Normalize(front.normal),
edgeLast = front.point;
distance = std::sqrt(Dot(point - anchor, point - anchor));
best = distance;
onEdge = false;
}
lastDistance = distance, lastHit = bestKey;
}
const bool onScene = !dragging && ((bestScene && best < 1e8) || onEdge);
const bool onPanel = dragging || (best < 1e8 && !onScene);
Spot s;
s.dragging = dragging, s.dir = dir, s.point = point, s.best = best, s.distance = distance;
s.onEdge = onEdge, s.occluded = occluded, s.onScene = onScene, s.onPanel = onPanel;
return s;
}
// Our dot where the cursor is (see "Looks" and "SteamVR Settings" at the top).
void Pointer::DrawDot(const Frame &frame, const Spot &spot) {
const auto tnow = frame.tnow;
const Vec3 &eye = frame.eye;
const bool dragging = spot.dragging, onScene = spot.onScene, onPanel = spot.onPanel;
const Vec3 dir = spot.dir, point = spot.point;
const Vec3 sight = Normalize(point - eye);
if (!dragging) {
// SteamVR Settings (see the top): the dashboard's main panel is hidden and its
// scene-graph panel shows the page.
onVrSettings = false;
const auto mainIt = visible.find("valve.steam.gamepadui.main");
if (overlay->IsDashboardVisible() && !(mainIt != visible.end() && mainIt->second)) {
for (const auto &[key, handle] : handles) {
if (!visible[key] || key.rfind("valve.steam.gamepadui.frame.menu.", 0) != 0) continue;
vr::ETrackingUniverseOrigin uo;
vr::HmdMatrix34_t t{};
if (overlay->GetOverlayTransformAbsolute(handle, &uo, &t) == vr::VROverlayError_None &&
OnSettingsPage(t, eye, sight))
onVrSettings = true;
}
}
}
if (onVrSettings != catcherHidesHit) {
overlay->SetOverlayFlag(cursor, vr::VROverlayFlags_HideLaserIntersection, onVrSettings);
catcherHidesHit = onVrSettings;
}
if (onVrSettings) {
// The dot close in front of the page, which is nearer than any guess of ours;
// the laser-catching dot far behind everything, invisible, so it never covers
// the page, with SteamVR's hit dot hidden on it.
const Vec3 near = eye + sight * SETTINGS_DOT, far = eye + sight * SETTINGS_CATCHER;
double alpha = 1;
if (gazeOn && !cfg.gazeDotAlways) {
auto secs = [&](Clock::time_point t) { return std::chrono::duration<double>(tnow - t).count(); };
alpha = std::clamp(1 - std::min(secs(lastMove) - cfg.gazeShow, secs(lastHeld)) / 0.25, 0.0, 1.0);
}
if (tnow < calPanelUntil) alpha = 0;
overlay->SetOverlayAlpha(marker, float(alpha));
overlay->SetOverlayWidthInMeters(marker, float(2 * SETTINGS_DOT * std::tan(cfg.cursorDeg * M_PI / 360)));
auto mm = Billboard(near, eye);
overlay->SetOverlayTransformAbsolute(marker, vr::TrackingUniverseStanding, &mm);
overlay->SetOverlayAlpha(cursor, 0);
overlay->SetOverlayWidthInMeters(cursor, float(2 * SETTINGS_CATCHER * std::tan(cfg.cursorDeg * M_PI / 360)));
auto mc = Billboard(far, eye);
overlay->SetOverlayTransformAbsolute(cursor, vr::TrackingUniverseStanding, &mc);
overlay->ShowOverlay(marker);
overlay->ShowOverlay(cursor);
} else {
// On a panel: the non-interactive marker, pulled 5 mm toward the eye so it
// draws on top. In free space: the interactive dot the laser lands on.
const vr::VROverlayHandle_t show = onPanel ? marker : cursor, hide = onPanel ? cursor : marker;
const Vec3 at = onPanel ? point + Normalize(eye - point) * 0.005 : onScene ? point + dir * 0.05 : point;
const double dist = std::sqrt(Dot(at - eye, at - eye));
// Gaze mode: a pulse for each click, and with POINTER_GAZE_DOT=moving, shown only
// while something moves it or a press holds it (see the top); transparent
// otherwise, the laser still lands on it.
double scale = 1, alpha = 1;
if (gazeOn) {
auto secs = [&](Clock::time_point t) { return std::chrono::duration<double>(tnow - t).count(); };
alpha = cfg.gazeDotAlways ? 1.0 : std::clamp(1 - std::min(secs(lastMove) - cfg.gazeShow, secs(lastHeld)) / 0.25, 0.0, 1.0);
const double pulse = secs(pulseAt);
if (pulse < 0.6) {
scale = 1 + 1.5 * std::max(0.0, 1 - pulse / 0.3);
alpha = std::max(alpha, std::clamp((0.6 - pulse) / 0.3, 0.0, 1.0));
}
}
if (tnow < calPanelUntil) alpha = 0; // the calibration panel is up (see the top)
overlay->SetOverlayAlpha(show, float(alpha));
overlay->SetOverlayWidthInMeters(show, float(2 * dist * std::tan(scale * cfg.cursorDeg * M_PI / 360)));
auto m = Billboard(at, eye);
overlay->SetOverlayTransformAbsolute(show, vr::TrackingUniverseStanding, &m);
overlay->ShowOverlay(show);
overlay->HideOverlay(hide);
}
}
void Pointer::SendRay(const Frame &frame, const Spot &spot) {
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
const auto tnow = frame.tnow;
const Vec3 &eye = frame.eye;
const vr::TrackedDevicePose_t &hmdRaw = frame.hmdRaw;
const bool dragging = spot.dragging, occluded = spot.occluded, onEdge = spot.onEdge, onScene = spot.onScene;
const double best = spot.best, distance = spot.distance;
const Vec3 point = spot.point;
// Controller ray: from the eye, aimed at the cursor point, converted from the
// standing universe to raw tracking space via the HMD's pose in both.
const Vec3 aimStanding = Normalize(point - eye);
const auto &S = hmd.mDeviceToAbsoluteTracking, &R = hmdRaw.mDeviceToAbsoluteTracking;
const Vec3 aim = Rotate(R, RotateInverse(S, aimStanding)); // raw <- head <- standing
// Origin partway along the line of sight to the cursor (smaller hit dot).
const double toPoint = std::sqrt(Dot(point - eye, point - eye));
double originDist = std::max(0.0, std::min(toPoint * cfg.originFraction, toPoint - cfg.originMargin));
if (onVrSettings) originDist = std::min(originDist, SETTINGS_ORIGIN); // SteamVR finds the page
const Vec3 originStanding = eye + Normalize(point - eye) * originDist;
const Vec3 eyeRaw = Position(R) + Rotate(R, RotateInverse(S, originStanding - eye));
lastPoint = point, lastOrigin = originStanding, lastAim = aimStanding; // tilt starts from here
havePoint = true;
if (debug && tnow - lastDebug > std::chrono::milliseconds(500)) {
lastDebug = tnow;
if (systemPointer == vr::k_ulOverlayHandleInvalid) overlay->FindOverlay("system.pointer", &systemPointer);
std::printf("dbg %s hit=%s dist=%.2f eye->point=%.2f origin=%.2f vrsettings=%d yaw=%.1f pitch=%.1f gaze=%s steamvr_dot=%d primary=%u\n",
dragging ? "DRAG" : occluded ? "INFRONT" : onEdge ? "EDGE" : onScene ? "SCENE" : (best < 1e8 ? "PANEL" : "FREE"), lastHit.empty() ? "-" : lastHit.c_str(),
distance, toPoint, originDist, onVrSettings, yaw, pitch,
!gazeOn ? "off" : tnow - gz.at > std::chrono::milliseconds(150) ? "stale" : gazeOwns ? "owns" : "mouse",
systemPointer != vr::k_ulOverlayHandleInvalid && overlay->IsOverlayVisible(systemPointer),
overlay->GetPrimaryDashboardDevice());
std::fflush(stdout);
}
const double ayaw = std::atan2(-aim.x, -aim.z) * 180 / M_PI;
const double apitch = std::asin(std::clamp(aim.y, -1.0, 1.0)) * 180 / M_PI;
if (dragging && (tiltYaw != 0 || tiltPitch != 0)) {
// Keep this drag's tilt applied, about the current cursor point.
const double yr = tiltYaw * M_PI / 180, pr = tiltPitch * M_PI / 180;
const Basis base = AimBasis(aimStanding);
const Vec3 up{0, 1, 0}, side = base.x;
auto turn = [&](Vec3 v) { return RotateAbout(RotateAbout(v, side, pr), up, yr); };
const Vec3 o = point + turn(originStanding - point);
const Basis b{turn(base.x), turn(base.y), turn(base.z)};
auto toRaw = [&](Vec3 v) { return Rotate(R, RotateInverse(S, v)); };
const Vec3 oRaw = Position(R) + toRaw(o - eye);
double q[4];
BasisQuat({toRaw(b.x), toRaw(b.y), toRaw(b.z)}, q);
char msg[200];
std::snprintf(msg, sizeof msg, "posq %.5f %.5f %.5f %.6f %.6f %.6f %.6f", oRaw.x, oRaw.y, oRaw.z, q[0],
q[1], q[2], q[3]);
SendTo(out, "ft_pointer", msg);
} else {
char msg[160];
std::snprintf(msg, sizeof msg, "pose %.5f %.5f %.5f %.4f %.4f", eyeRaw.x, eyeRaw.y, eyeRaw.z, ayaw, apitch);
SendTo(out, "ft_pointer", msg);
}
}
+91
View File
@@ -0,0 +1,91 @@
// ft-pointer: gaze mode and its held-back press (see "Gaze mode" at the top).
// "The top" in comments here is the header comment of ft-pointer.cpp.
#include "pointer.h"
// Outside games, gaze mode keeps the pointer (see the top); the relay needs to know.
void Pointer::GazeAwake() {
const auto t = Clock::now();
if (t - inGameAt > std::chrono::milliseconds(500)) {
inGameAt = t;
inGame = vr::VRApplications()->GetCurrentSceneProcessId() != 0;
}
// Hand gestures in the last two minutes keep it the same way.
const bool handsRecent = cfg.handsOn && handUsed != Clock::time_point{} && t - handUsed < std::chrono::minutes(2);
const bool awake = (gazeOn || handsRecent) && !inGame && !headsetOff;
if (awake != gazeAwake || t - gazeAwakeAt > std::chrono::seconds(5)) {
if (awake != gazeAwake)
std::printf("%s keeps the pointer: %s\n", gazeOn ? "gaze" : "hand use",
awake ? "yes" : "no (off, in a game, or headset off)");
if (awake != gazeAwake) std::fflush(stdout);
gazeAwake = awake;
gazeAwakeAt = t;
SendTo(out, "frametop_relay", awake ? "gazeawake 1" : "gazeawake 0");
}
}
// Gaze mode (see the top): the gaze has the pointer, or takes it back when you look
// well away from it. Not while a press holds the pointer, and only on fresh gaze.
void Pointer::GazeMode(const Frame &frame) {
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
const auto tnow = frame.tnow;
const Vec3 &eye = frame.eye;
if (hmd.bPoseIsValid) lastHead = hmd.mDeviceToAbsoluteTracking, haveHead = true;
if (gazeOn && active && hmd.bPoseIsValid && !tilting && !leftHeld && !aimHeld && !clickPress && !clickRelease &&
tnow >= dropHoldUntil &&
tnow - gz.at < std::chrono::milliseconds(150)) {
const Vec3 g = Rotate(hmd.mDeviceToAbsoluteTracking, Direction(gz.hy, gz.hp));
if (!gazeOwns && havePoint) {
const double off = std::acos(std::clamp(Dot(g, Normalize(lastPoint - eye)), -1.0, 1.0)) * 180 / M_PI;
if (off > cfg.gazeRetake && tnow - lastMouse > std::chrono::milliseconds(300)) {
if (retakeSince == Clock::time_point{}) retakeSince = tnow;
if (tnow - retakeSince >= std::chrono::milliseconds(120)) gazeOwns = true, nudging = false;
} else {
retakeSince = {};
}
}
if (gazeOwns) {
retakeSince = {};
anchor = eye;
anchored = true;
yaw = std::atan2(-g.x, -g.z) * 180 / M_PI;
pitch = std::clamp(std::asin(std::clamp(g.y, -1.0, 1.0)) * 180 / M_PI, -85.0, 85.0);
}
}
}
void Pointer::CalPanelOpened() {
if (calOpened) { // the calibration panel came up: a press in progress ends, no click
calOpened = false;
if (hold.src != Src::None) EndHold(true);
if (leftHeld) ReleaseLeft();
aimHeld = aimHand = clickPress = false;
}
}
// A held-back press (see the top): held still long enough, it's a real press (a drag);
// released, it's a click where the pointer is now (this frame's pose has gone out).
void Pointer::HeldBackPress(const Frame &frame) {
const auto tnow = frame.tnow;
if (!active) aimHeld = aimRight = clickPress = aimHand = confirmLesson = false; // released meanwhile: nothing to click
if (aimHeld && !aimHand && nudgeMoved < 0.2 && tnow - aimSince >= std::chrono::duration<double>(cfg.gazeHold)) {
aimHeld = false;
gazeBack = true;
pressRight = aimRight; // the right button's: a real right press (see the top)
aimRight = false;
PressLeft();
}
}
// The click a held-back press ends in: the press now, the release 40 ms later.
void Pointer::Click(const Frame &frame) {
const auto tnow = frame.tnow;
if (clickPress) {
clickPress = false;
PressLeft();
clickRelease = true;
clickReleaseAt = tnow + std::chrono::milliseconds(40);
} else if (clickRelease && tnow >= clickReleaseAt) {
clickRelease = false;
ReleaseLeft();
}
}
+202
View File
@@ -0,0 +1,202 @@
// ft-pointer: holds, hands, and the gaze precision buttons (see "Gaze precision" and "Hands" at
// the top).
// "The top" in comments here is the header comment of ft-pointer.cpp.
#include "pointer.h"
// Hands (see the top): pinches and grips from ft-hands.
void Pointer::Hands(const Frame &frame) {
const auto tnow = frame.tnow;
vr::TrackedDevicePose_t h0;
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &h0, 1);
if (h0.bPoseIsValid) poses.Add(tnow, h0.mDeviceToAbsoluteTracking);
}
// Where a gesture's point is, seen from the hold's origin: yaw and pitch, degrees.
bool Pointer::HandAngles(const float p[3], uint64_t t_ns, const Vec3 &from, double &hy, double &hp) {
vr::HmdMatrix34_t head;
if (!poses.At(t_ns, head)) return false;
const Vec3 d = Normalize(Position(head) + Rotate(head, Vec3{p[0], p[1], p[2]}) - from);
hy = std::atan2(-d.x, -d.z) * 180 / M_PI;
hp = std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI;
return true;
}
void Pointer::EndHold(bool lost) {
if (hold.pressed) {
if (leftHeld) ReleaseLeft();
} else if (aimHeld) {
aimHeld = aimHand = false;
if (lost) {
if (gazeOn) gazeOwns = true, nudging = false; // no click: the pointer goes back to the gaze
} else {
clickPress = true; // the click is on the release, where the pointer is now
pressRight = hold.right;
gazeBack = nudgeMoved < 0.2;
}
}
if (debug)
std::printf("hold %s %s%s\n", hold.src == Src::Hand ? (hold.grip ? "grip" : "pinch")
: hold.src == Src::Head ? (hold.right ? "key right" : "key left")
: hold.grip ? "gaze drag" : "gaze precision",
lost ? "lost" : "released", hold.pressed ? "" : lost ? " (no click)" : " (click)");
if (debug) std::fflush(stdout);
hold = {};
}
// The press, once a hold's source is set: a real press (dragging until the release), or
// held back like gaze mode's mouse press (see the top): the click comes on the release.
void Pointer::StartHold(bool press, Clock::time_point tnow) {
hold.startYaw = hold.lastYaw = yaw, hold.startPitch = hold.lastPitch = pitch;
hold.pressed = press;
if (press) {
gazeBack = gazeOn && gazeOwns;
PressLeft();
} else {
gazeOwns = false;
nudging = gazeOn && haveHead && tnow - gz.at < std::chrono::milliseconds(200);
nudgeRawHy = gz.rhy, nudgeRawHp = gz.rhp, nudgeHead = lastHead;
nudgeAt = aimSince = tnow, nudgeMoved = 0;
aimHeld = aimHand = true;
pulseAt = tnow;
}
}
// The hold's source moved to (hy, hp): past the dead zone, the pointer follows at the gain,
// from where it was when it got past.
void Pointer::Steer(double hy, double hp, double deadzone, double gain, Clock::time_point tnow) {
const double dy = std::remainder(hy - hold.refYaw, 360.0), dp = hp - hold.refPitch;
if (!hold.engaged) {
if (std::hypot(dy, dp) <= deadzone) return;
hold.engaged = true;
hold.refYaw = hy, hold.refPitch = hp;
hold.startYaw = hold.lastYaw = yaw, hold.startPitch = hold.lastPitch = pitch;
return;
}
yaw = std::remainder(hold.startYaw + gain * dy, 360.0);
pitch = std::clamp(hold.startPitch + gain * dp, -85.0, 85.0);
// How far the nudge went: for a keyboard click, from where the dot was at the press
// (a head wobbles on the way); otherwise the path, as with the mouse.
if (!hold.pressed && hold.src == Src::Head)
nudgeMoved = std::hypot(std::remainder(yaw - hold.pressYaw, 360.0), pitch - hold.pressPitch);
else if (!hold.pressed)
nudgeMoved += std::hypot(std::remainder(yaw - hold.lastYaw, 360.0), pitch - hold.lastPitch);
hold.lastYaw = yaw, hold.lastPitch = pitch;
lastMove = tnow; // the dot shows while it moves (gaze mode)
}
void Pointer::BeginHold(int side, bool grip, const fh_pinch_t &g, Clock::time_point tnow) {
handUsed = tnow;
if (hold.src != Src::None) {
// A grip takes over a pinch on the way to it (closing the hand passes through
// one); otherwise one hold at a time.
if (hold.src != Src::Hand || !grip || hold.grip) return;
aimHeld = aimHand = false;
hold = {};
}
if (leftHeld || aimHeld || clickPress || clickRelease) return; // the mouse's button is busy
if (!active) {
Wake(tnow); // the first gesture only wakes the pointer, like the first mouse move
return;
}
vr::HmdMatrix34_t head;
if (!poses.At(g.begin_ns, head)) return;
// No pinch just after a key (typing touches thumb to index), and a grip only with
// the hand held up (hands on a desk curl like a loose fist; looking down at them
// puts them straight ahead in the head's frame, where ft-hands can't tell).
const Vec3 at = Position(head) + Rotate(head, Vec3{g.begin_point[0], g.begin_point[1], g.begin_point[2]});
if (!grip && tnow - lastTyping < std::chrono::duration<double>(cfg.typingHold)) {
if (debug) std::printf("hand pinch ignored: typing\n");
return;
}
if (grip && Position(head).y - at.y > cfg.handBelow) {
if (debug) std::printf("hand grip ignored: %.2f m below the eyes\n", Position(head).y - at.y);
return;
}
Hold h;
h.src = Src::Hand, h.side = side, h.grip = grip, h.origin = Position(head);
if (!HandAngles(g.begin_point, g.begin_ns, h.origin, h.refYaw, h.refPitch)) return;
hold = h;
// A grip, or a pinch without gaze mode: a real press where the pointer is (where
// you look, in gaze mode), dragging with the hand until it opens.
StartHold(grip || !gazeOn, tnow);
if (debug) std::printf("hand %s %s began\n", side ? "right" : "left", grip ? "grip" : "pinch");
if (debug) std::fflush(stdout);
}
// Gaze precision and gaze drag buttons (see the top): the mouse steers.
void Pointer::GazePrecisionButtons(const Frame &frame) {
const auto tnow = frame.tnow;
for (const DevicePress &p : devicePresses) {
if (p.source == "left" || p.source == "right") continue; // no controllers in gaze mode
if (!p.down) {
if (hold.src == Src::Mouse) EndHold(false);
continue;
}
if (hold.src != Src::None || leftHeld || aimHeld || clickPress || clickRelease) continue;
Hold h;
h.src = Src::Mouse, h.grip = p.drag;
hold = h;
StartHold(p.drag, tnow);
if (debug) std::printf("hold gaze %s began (%s)\n", p.drag ? "drag" : "precision", p.source.c_str());
if (debug) std::fflush(stdout);
}
devicePresses.clear();
}
// Pinches and grips from ft-hands (see "Hands" at the top), read every frame.
void Pointer::PinchesAndGrips(Frame &frame) {
const auto tnow = frame.tnow;
fh_gestures_t hg;
const bool handOk = cfg.handsOn && handFile.Read(hg);
frame.handOk = handOk;
if (handOk && (hg.seq != handSeq || handFile.opens != handOpens)) {
handSeq = hg.seq;
handPublished = hg.publish_ns;
const fh_pinch_t *slots[2] = {hg.pinch, hg.grip};
// A new file, or a tracker whose counters went back: start counting from here.
bool rebase = !handBaseline || handFile.opens != handOpens;
for (int k = 0; k < 2; ++k)
for (int s = 0; s < 2; ++s)
rebase = rebase || slots[k][s].begins < seenBegins[k][s] || slots[k][s].ends < seenEnds[k][s];
if (rebase) {
if (hold.src == Src::Hand) EndHold(true);
for (int k = 0; k < 2; ++k)
for (int s = 0; s < 2; ++s) seenBegins[k][s] = slots[k][s].begins, seenEnds[k][s] = slots[k][s].ends;
handBaseline = true, handOpens = handFile.opens;
}
// Not over a VR game (unless the dashboard is up), and not with the headset off.
const bool allowed = !headsetOff && (!inGame || overlay->IsDashboardVisible());
for (int k = 1; k >= 0; --k) // grips first: one takes over a pinch
for (int s = 0; s < 2; ++s) {
const fh_pinch_t &g = slots[k][s];
const bool began = g.begins != seenBegins[k][s], ended = g.ends != seenEnds[k][s];
const bool lost = g.flags & FH_PINCH_LOST;
seenBegins[k][s] = g.begins, seenEnds[k][s] = g.ends;
auto holding = [&] { return hold.src == Src::Hand && hold.side == s && hold.grip == (k == 1); };
if (holding() && ended) EndHold(lost); // the one held ended (another may have begun)
if (began && allowed) {
BeginHold(s, k == 1, g, tnow);
// begun and ended since the last read (a quick tap): its release too
if (!(g.flags & FH_PINCH_DOWN) && holding()) EndHold(lost);
}
}
// The held gesture's hand moves the pointer: past the dead zone (a tap's jitter,
// the pinch point shifting as the fingers close), then at the gain, from there.
if (hold.src == Src::Hand) {
const fh_pinch_t &g = hold.grip ? hg.grip[hold.side] : hg.pinch[hold.side];
double hy, hp;
if ((g.flags & FH_PINCH_TRACKED) && HandAngles(g.point, hg.capture_ns, hold.origin, hy, hp))
Steer(hy, hp, hold.grip ? 0.5 : cfg.pinchDeadzone, hold.grip ? cfg.gripGain : cfg.pinchGain, tnow);
}
}
}
// ft-hands stopped (or hung) with a gesture held: it's over, as lost.
void Pointer::HandsStopped(const Frame &frame) {
const auto tnow = frame.tnow;
const bool handOk = frame.handOk;
const uint64_t nowNs =
uint64_t(std::chrono::duration_cast<std::chrono::nanoseconds>(tnow.time_since_epoch()).count());
if (hold.src == Src::Hand && (!handOk || !active || nowNs - handPublished > 1'500'000'000ull)) EndHold(true);
if (hold.src != Src::None && hold.src != Src::Hand && !active) EndHold(true);
}
+390
View File
@@ -0,0 +1,390 @@
// ft-pointer: waking the pointer, its buttons, and the commands from the relay.
// "The top" in comments here is the header comment of ft-pointer.cpp.
#include "pointer.h"
void Pointer::Wake(Clock::time_point t) {
if (t < noWakeUntil || headsetOff) return;
wokeAt = t;
active = true;
recenter = true;
SendTo(out, "ft_pointer", "role " + cfg.role); // POINTER_ROLE, before it takes it
SendTo(out, "ft_pointer", "show");
claimPending = true; // take the laser without clicking, once SteamVR has bound the device
claimAt = t + std::chrono::milliseconds(300);
}
void Pointer::PressLeft() {
// ft-screens sends the keyboard to the panel clicked last; it sees clicks on
// its own screens, but only we know when one lands on another panel.
SendTo(out, "ft_screens", "click " + (lastHit.empty() ? std::string("-") : lastHit));
// A click after nudging the gaze-placed pointer: the nudge is a lesson, or past
// POINTER_GAZE_NUDGE_MAX, a quick check (see the top).
if (gazeOn && nudging && !gazeOwns && havePoint && Clock::now() - nudgeAt < std::chrono::seconds(10) &&
(confirmLesson || nudgeMoved >= 0.2)) {
const Vec3 d = RotateInverse(nudgeHead, Normalize(lastPoint - Position(nudgeHead)));
const double ty = std::atan2(-d.x, -d.z) * 180 / M_PI, tp = std::asin(std::clamp(d.y, -1.0, 1.0)) * 180 / M_PI;
const double off = std::hypot(std::remainder(ty - nudgeRawHy, 360.0), tp - nudgeRawHp);
char msg[160];
if (off <= cfg.gazeNudgeMax)
std::snprintf(msg, sizeof msg, "lesson %.3f %.3f %.3f %.3f", nudgeRawHy, nudgeRawHp, ty, tp);
else
std::snprintf(msg, sizeof msg, "recheck %.0f", off);
SendTo(out, "ft_gazed", msg);
if (debug) std::printf("gaze %s (nudged %.2f deg, off %.2f)\n", msg, nudgeMoved, off);
if (debug) std::fflush(stdout);
}
nudging = confirmLesson = false;
leftHeld = true;
dragDistance = lastDistance;
pressKey.clear();
if (FramePanel(lastHit)) {
vr::ETrackingUniverseOrigin uo;
auto it = handles.find(lastHit);
if (it != handles.end() &&
overlay->GetOverlayTransformAbsolute(it->second, &uo, &pressPose) == vr::VROverlayError_None)
pressKey = lastHit;
}
tiltYaw = tiltPitch = 0; // a new drag starts untilted
dropHoldUntil = {};
pulseAt = Clock::now();
heldButton = pressRight ? "b" : "trigger";
pressRight = false;
SendTo(out, "ft_pointer", "btn " + heldButton + " 1");
}
void Pointer::ReleaseLeft() {
leftHeld = false;
tilting = false;
// Hold the drag pose (tilt, frozen distance) while SteamVR finishes the drop.
dropHoldUntil = Clock::now() + std::chrono::milliseconds(500);
SendTo(out, "ft_pointer", "btn " + heldButton + " 0");
// ft-screens releases a button held on its screens in KWin even when SteamVR hands
// the release to some other overlay (its catcher usually gets it; this is the backstop).
SendTo(out, "ft_screens", "up");
if (gazeBack) gazeOwns = true, gazeBack = false;
}
// The left button, from the relay's "btn trigger", with gaze mode's held-back press (see
// the top).
bool Pointer::CanAim() {
return gazeOn && cfg.gazeMousePrecision && gazeOwns && !aimHeld && !clickPress && !clickRelease &&
hold.src == Src::None;
}
// Hold the press back: the pointer stops where the gaze put it.
void Pointer::AimStart(bool right) {
gazeOwns = false;
nudging = haveHead && Clock::now() - gz.at < std::chrono::milliseconds(200);
nudgeRawHy = gz.rhy, nudgeRawHp = gz.rhp, nudgeHead = lastHead;
nudgeAt = aimSince = Clock::now(), nudgeMoved = 0;
aimHeld = true, aimRight = right;
}
// A drag the right button began (chordDrag): it lasts while either button is held.
void Pointer::ChordDrop() {
chordDrag = false;
if (leftHeld) ReleaseLeft();
if (debug) std::printf("mouse drag dropped\n");
if (debug) std::fflush(stdout);
}
void Pointer::LeftButton(bool down) {
leftDown = down;
if (down) {
if (aimHeld && aimRight && !aimHand) {
ignoreLeftUp = true; // the right's press is held back: the left does nothing
return;
}
if (CanAim()) {
AimStart(false);
return;
}
PressLeft();
return;
}
if (ignoreLeftUp) {
ignoreLeftUp = false;
return;
}
if (chordDrag) {
if (!rightDown) ChordDrop(); // otherwise the right holds it (tilting, maybe)
return;
}
if (aimHeld && !aimHand && !aimRight) {
aimHeld = false;
clickPress = true; // after this frame's pose, so it lands where the pointer was moved to
gazeBack = nudgeMoved < 0.2;
return;
}
if (leftHeld) ReleaseLeft();
}
// The right button (see the top); false: not taken here (a tilt, or passed on as it is).
bool Pointer::RightButton(bool down) {
rightDown = down;
if (down) {
if (aimHeld && !aimHand && !aimRight) {
// During the left's held-back press: press the left where the pointer is now (the
// correction is a lesson), and the mouse drags.
aimHeld = false;
chordDrag = gazeBack = true;
PressLeft();
if (debug) std::printf("mouse drag began (right during left)\n");
if (debug) std::fflush(stdout);
return true;
}
if (CanAim() && !leftHeld) {
AimStart(true);
return true;
}
return false;
}
if (chordDrag) {
if (leftDown) return !swallowedRight; // the left holds it; a tilt's release ends the tilt
tilting = swallowedRight = false;
ChordDrop();
return true;
}
if (aimHeld && !aimHand && aimRight) {
aimHeld = aimRight = false;
pressRight = clickPress = true; // the right click, where the pointer was moved to
gazeBack = nudgeMoved < 0.2;
return true;
}
if (leftHeld && heldButton == "b") { // its press, held still into a real one
ReleaseLeft();
return true;
}
return false;
}
// POINTER_GAZE_MOUSE_MOVE=held (see the top): the gaze is fresh and nothing is pressed, so a
// mouse move doesn't move the pointer.
bool Pointer::MouseMoveHeld() {
return gazeOn && cfg.gazeMouseHeld && !inGame && !headsetOff && Clock::now() - gz.at < std::chrono::seconds(1) &&
!aimHeld && !leftHeld && !tilting && hold.src == Src::None && !clickPress && !clickRelease;
}
// Commands from the relay.
void Pointer::RelayCommands() {
char buf[256];
ssize_t n;
sockaddr_un from{};
socklen_t fromLen = sizeof from;
while ((n = recvfrom(in, buf, sizeof buf - 1, 0, reinterpret_cast<sockaddr *>(&from), &fromLen)) > 0) {
buf[n] = 0;
// Reply to the sender (the layout tool binds an abstract address to get answers).
const sockaddr_un sender = from;
const socklen_t senderLen = fromLen;
fromLen = sizeof from;
Command(buf, sender, senderLen);
}
}
// One command from the relay (or another sender: reply answers it).
void Pointer::Command(const char *buf, const sockaddr_un &sender, socklen_t senderLen) {
auto reply = [&](const std::string &msg) {
if (senderLen > offsetof(sockaddr_un, sun_path))
sendto(out, msg.data(), msg.size(), 0, reinterpret_cast<const sockaddr *>(&sender), senderLen);
};
// The gaze, from ft-gazed: not mouse input, it never wakes the pointer.
double g[4];
if (std::sscanf(buf, "gz %lf %lf %lf %lf", &g[0], &g[1], &g[2], &g[3]) == 4) {
gz = {g[0], g[1], g[2], g[3], Clock::now()};
return;
}
// The gaze calibration panel (see the top): presses answer it instead of clicking.
{
int on;
if (std::sscanf(buf, "calpanel %d", &on) == 1) {
const bool was = Clock::now() < calPanelUntil;
calPanelUntil = on ? Clock::now() + std::chrono::seconds(3) : Clock::time_point{};
if (on && !was) calOpened = true;
return;
}
}
if (Clock::now() < calPanelUntil) {
const bool accept = !std::strncmp(buf, "btn trigger 1", 13) || !std::strncmp(buf, "gazekey left 1", 14);
const bool quit = !std::strncmp(buf, "btn b 1", 7) || !std::strncmp(buf, "gazekey right 1", 15);
if (accept || quit) {
SendTo(out, "ft_gazed", accept ? "calaccept" : "calquit");
return;
}
if (!std::strncmp(buf, "btn ", 4) || !std::strncmp(buf, "gazekey ", 8) ||
!std::strncmp(buf, "precision ", 10) || !std::strncmp(buf, "gazedrag ", 9))
return; // their releases, and the other buttons: nothing to click now
}
{
char kind[16], source[16];
int v;
if (std::sscanf(buf, "%15s %15s %d", kind, source, &v) == 3 &&
(!std::strcmp(kind, "precision") || !std::strcmp(kind, "gazedrag"))) {
lastMouse = Clock::now();
if (!active) Wake(Clock::now());
devicePresses.push_back({source, !std::strcmp(kind, "gazedrag"), v != 0});
return;
}
if (std::sscanf(buf, "gazekey %15s %d", source, &v) == 2 &&
(!std::strcmp(source, "left") || !std::strcmp(source, "right"))) {
lastMouse = Clock::now();
if (!active) Wake(Clock::now());
keyPresses.push_back({!std::strcmp(source, "right"), v != 0});
return;
}
}
if (std::strcmp(buf, "typing") == 0) { // not mouse input: it never wakes the pointer
lastTyping = Clock::now();
return;
}
if (std::strncmp(buf, "vrbind", 6) == 0) {
std::printf("controller buttons: %s\n", controllerButtons.Bind(buf + 6).c_str());
std::fflush(stdout);
return;
}
if (std::strncmp(buf, "vrglobal", 8) == 0) {
const char *arg = buf + 8;
while (*arg == ' ') ++arg;
ControllerButtons::SetGlobal(std::strncmp(arg, "off", 3) != 0);
reply(controllerButtons.Status());
return;
}
if (std::strncmp(buf, "vrstatus", 8) == 0) {
reply(controllerButtons.Status());
return;
}
if (std::strncmp(buf, "overlays", 8) == 0) {
overlays.Request(sender, senderLen); // answered from the list's thread
return;
}
if (std::strncmp(buf, "gaze", 4) == 0) {
const char *arg = buf + 4;
while (*arg == ' ') ++arg;
if (*arg != '?') { // "gaze ?" only asks
gazeOn = std::strncmp(arg, "on", 2) == 0 ? true
: std::strncmp(arg, "off", 3) == 0 ? false
: !gazeOn;
gazeOwns = true, nudging = false;
std::printf("gaze mode %s\n", gazeOn ? "on" : "off");
std::fflush(stdout);
}
reply(gazeOn ? "ok on" : "ok off");
return;
}
const bool mouseInput = std::strncmp(buf, "move", 4) == 0 || std::strncmp(buf, "btn", 3) == 0 ||
std::strncmp(buf, "scroll", 6) == 0;
// A move held back (POINTER_GAZE_MOUSE_MOVE=held) only wakes the pointer: it isn't using
// the mouse, so a drifting mouse doesn't keep the gaze from taking the pointer back.
const bool moveHeldBack = std::strncmp(buf, "move", 4) == 0 && MouseMoveHeld();
if (mouseInput && !moveHeldBack) lastMouse = Clock::now();
// Any mouse input wakes the pointer (after a controller took over, or a helper restart).
if (!active && mouseInput) Wake(Clock::now());
if (moveHeldBack) return;
double a, b;
char key[128];
double px, py, pz, pyaw, ppitch, proll = 0, pgrab = -1;
if (std::sscanf(buf, "grabprobe %127s", key) == 1) {
GrabProbe(key);
return;
}
if (std::sscanf(buf, "place %127s %lf %lf %lf %lf %lf %lf %lf", key, &px, &py, &pz, &pyaw, &ppitch, &proll,
&pgrab) >= 6) {
reply(Place(key, {px, py, pz}, PanelBasis(pyaw, ppitch, proll), pgrab >= 0 ? pgrab : cfg.grabOffset));
return;
}
if (std::sscanf(buf, "measure %127s", key) == 1) {
Panel p;
Vec3 eye;
const std::string err = FindPanel(key, p, eye);
char msg[400] = "";
if (err.empty())
std::snprintf(msg, sizeof msg, "ok %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f",
p.center.x, p.center.y, p.center.z, p.width, p.height, p.basis.x.x, p.basis.x.y,
p.basis.x.z, p.basis.y.x, p.basis.y.y, p.basis.y.z, p.basis.z.x, p.basis.z.y,
p.basis.z.z);
reply(err.empty() ? msg : "error " + err);
return;
}
if (std::strncmp(buf, "head", 4) == 0) {
vr::TrackedDevicePose_t h;
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &h, 1);
const Vec3 e = Position(h.mDeviceToAbsoluteTracking);
const Vec3 f = Rotate(h.mDeviceToAbsoluteTracking, {0, 0, -1});
char msg[200];
std::snprintf(msg, sizeof msg, "ok %.4f %.4f %.4f %.2f %.2f", e.x, e.y, e.z,
std::atan2(-f.x, -f.z) * 180 / M_PI, std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI);
reply(h.bPoseIsValid ? msg : "error no head pose (headset off?)");
return;
}
if (std::strncmp(buf, "debug", 5) == 0) {
debug = !debug;
std::printf("debug %s\n", debug ? "on" : "off");
std::fflush(stdout);
return;
}
if (std::strncmp(buf, "btn trigger 1", 13) == 0) {
LeftButton(true);
return;
} else if (std::strncmp(buf, "btn trigger 0", 13) == 0) {
LeftButton(false);
return;
} else if (std::strncmp(buf, "btn b 1", 7) == 0 && RightButton(true)) {
return;
} else if (std::strncmp(buf, "btn b 0", 7) == 0 && RightButton(false)) {
return;
} else if (std::strncmp(buf, "btn b 1", 7) == 0 && leftHeld) {
tilting = tiltStart = swallowedRight = true; // right press while dragging: tilt, no right-click
return;
} else if (std::strncmp(buf, "btn b 0", 7) == 0 && swallowedRight) {
tilting = swallowedRight = false;
return;
}
if (tilting && std::sscanf(buf, "move %lf %lf", &a, &b) == 2) {
tiltYaw += a;
tiltPitch = std::clamp(tiltPitch + b, -80.0, 80.0);
return;
}
if (std::sscanf(buf, "move %lf %lf", &a, &b) == 2) {
lastMove = Clock::now(); // the dot shows while the mouse moves it (gaze mode)
if (gazeOn && gazeOwns) {
// The mouse takes the pointer from the gaze, from where the gaze left it.
gazeOwns = false;
nudging = haveHead && Clock::now() - gz.at < std::chrono::milliseconds(200);
nudgeRawHy = gz.rhy, nudgeRawHp = gz.rhp, nudgeHead = lastHead;
nudgeAt = Clock::now(), nudgeMoved = 0;
}
if (nudging || aimHeld) nudgeMoved += std::hypot(a, b);
if (!anchored) recenter = true;
yaw += a;
while (yaw > 180) yaw -= 360;
while (yaw < -180) yaw += 360;
pitch = std::clamp(pitch + b, -85.0, 85.0);
} else if (std::strncmp(buf, "recenter", 8) == 0) {
recenter = true;
} else if (std::strncmp(buf, "reload", 6) == 0) {
cfg.Load(ReadConfig());
ApplyConfig();
lastSlow = Clock::now() - std::chrono::seconds(10); // apply POINTER_IGNORE now
std::printf("reloaded: free distance %.2f m, dot %.2f deg, origin %.2f, head follow %s, leash %.0f deg, "
"controller pickup %.1fx, %zu ignored\n",
cfg.freeDistance, cfg.cursorDeg, cfg.originFraction, follow ? "on" : "off", cfg.leashDeg, cfg.pickupScale,
cfg.ignore.size());
std::fflush(stdout);
} else if (std::strncmp(buf, "follow", 6) == 0) {
const char *arg = buf + 6;
while (*arg == ' ') ++arg;
const bool was = follow;
follow = std::strncmp(arg, "on", 2) == 0 ? true : std::strncmp(arg, "off", 3) == 0 ? false : !follow;
if (follow && !was) followReset = true;
std::printf("head follow %s (leash %.0f deg)\n", follow ? "on" : "off", cfg.leashDeg);
std::fflush(stdout);
} else if (std::strncmp(buf, "show", 4) == 0) {
if (!active) Wake(Clock::now());
} else if (std::strncmp(buf, "hide", 4) == 0) {
active = false;
overlay->HideOverlay(cursor);
overlay->HideOverlay(marker);
SendTo(out, "ft_pointer", "hide");
} else {
SendTo(out, "ft_pointer", buf); // btn, scroll
}
}
+113
View File
@@ -0,0 +1,113 @@
// ft-pointer: keyboard clicks (see "Keyboard clicks" under "Gaze precision" at the top).
// "The top" in comments here is the header comment of ft-pointer.cpp.
#include "pointer.h"
// Where the head faces: yaw and pitch, degrees.
bool Pointer::HeadAngles(const vr::TrackedDevicePose_t &hmd, double &hy, double &hp) {
const auto &hm = hmd.mDeviceToAbsoluteTracking.m;
if (!hmd.bPoseIsValid) return false;
const Vec3 f = Normalize({-hm[0][2], -hm[1][2], -hm[2][2]});
hy = std::atan2(-f.x, -f.z) * 180 / M_PI;
hp = std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI;
return true;
}
// Keyboard clicks (see the top): held back at the gaze, steered by the head.
void Pointer::KeyboardClicks(const Frame &frame) {
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
const auto tnow = frame.tnow;
for (const KeyPress &k : keyPresses) {
(k.right ? keyRightDown : keyLeftDown) = k.down;
if (!k.down) {
if (keyTilting && k.right) { // the tilt ends; the head drags again from here
keyTilting = tilting = false;
hold.engaged = false;
if (debug) std::printf("hold key left: tilt ended\n");
if (debug) std::fflush(stdout);
}
// The keys holding it: a gaze_left then gaze_right drag, either; otherwise its own.
const bool held = hold.keyDrag ? keyLeftDown || keyRightDown : hold.right ? keyRightDown : keyLeftDown;
if (hold.src == Src::Head && !held) {
if (!hold.pressed && aimHeld && tnow - aimSince < std::chrono::duration<double>(cfg.keyTap)) {
// A quick tap: a click where the dot was at the press, and the gaze was right.
yaw = hold.pressYaw, pitch = hold.pressPitch;
nudgeMoved = 0;
confirmLesson = true;
}
EndHold(false);
keyTilting = false;
}
continue;
}
if (k.right && hold.src == Src::Head && !hold.right && hold.pressed && leftHeld) {
// gaze_right during a gaze_left drag: tilt while it's held (see the top).
tilting = tiltStart = keyTilting = true;
HeadAngles(hmd, keyTiltYaw, keyTiltPitch);
if (debug) std::printf("hold key left: tilt began\n");
if (debug) std::fflush(stdout);
continue;
}
if (k.right && hold.src == Src::Head && !hold.right && !hold.pressed && aimHeld) {
// gaze_right while gaze_left aims: press the left button where the dot is now
// (the correction is a lesson), then the head drags.
aimHeld = aimHand = false;
hold.pressed = hold.grip = hold.keyDrag = true, hold.promote = false, hold.engaged = false;
HeadAngles(hmd, hold.refYaw, hold.refPitch);
gazeBack = gazeOn;
pressRight = false;
PressLeft();
if (debug) std::printf("hold key left: pressed (right key)\n");
if (debug) std::fflush(stdout);
continue;
}
if (hold.src != Src::None || leftHeld || aimHeld || clickPress || clickRelease) continue;
Hold h;
h.src = Src::Head, h.promote = true, h.right = k.right, h.pressYaw = yaw, h.pressPitch = pitch;
if (!HeadAngles(hmd, h.refYaw, h.refPitch)) continue;
hold = h;
StartHold(false, tnow);
if (debug) std::printf("hold key %s began\n", k.right ? "right" : "left");
if (debug) std::fflush(stdout);
}
keyPresses.clear();
}
// A keyboard click's hold: the head steers the pointer, or turns the panel in a tilt.
void Pointer::HeadSteer(const Frame &frame) {
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
const auto tnow = frame.tnow;
if (hold.src == Src::Head) {
double hy, hp;
if (HeadAngles(hmd, hy, hp) && keyTilting) {
// The head turns the panel, as the mouse does in a tilt; the pointer stays put.
tiltYaw += std::remainder(hy - keyTiltYaw, 360.0);
tiltPitch = std::clamp(tiltPitch + hp - keyTiltPitch, -80.0, 80.0);
keyTiltYaw = hy, keyTiltPitch = hp;
} else if (HeadAngles(hmd, hy, hp)) {
Steer(hy, hp, hold.pressed ? 0.3 : cfg.headDeadzone, 1.0, tnow);
}
}
}
// A keyboard click held still for POINTER_GAZE_HOLD: a real press, then the head drags
// (from where it is now).
void Pointer::KeyboardClickHold(const Frame &frame) {
const vr::TrackedDevicePose_t &hmd = frame.Hmd();
const auto &hm = hmd.mDeviceToAbsoluteTracking.m;
const auto tnow = frame.tnow;
if (aimHeld && aimHand && hold.promote && !hold.engaged &&
tnow - aimSince >= std::chrono::duration<double>(cfg.gazeHold)) {
aimHeld = aimHand = false;
hold.pressed = hold.grip = true;
if (hmd.bPoseIsValid) {
const Vec3 f = Normalize({-hm[0][2], -hm[1][2], -hm[2][2]});
hold.refYaw = std::atan2(-f.x, -f.z) * 180 / M_PI;
hold.refPitch = std::asin(std::clamp(f.y, -1.0, 1.0)) * 180 / M_PI;
}
gazeBack = gazeOn;
pressRight = hold.right;
PressLeft();
if (debug) std::printf("hold key %s: pressed (held still)\n", hold.right ? "right" : "left");
if (debug) std::fflush(stdout);
}
}
+168
View File
@@ -0,0 +1,168 @@
// ft-pointer: panel placement for layouts (see "Placement" at the top).
// "The top" in comments here is the header comment of ft-pointer.cpp.
#include "pointer.h"
namespace {
void SleepMs(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
} // namespace
// Placement speeds (see "Placement" at the top).
constexpr double kPlaceDegPerSec = 60; // tested: 40 deg/s is applied exactly
// --- Panel placement (see "Placement" at the top of the file) ---
// Device pose, given in the standing universe, sent to the driver in raw space.
void Pointer::SendPose(Vec3 originStanding, const Basis &b) {
vr::TrackedDevicePose_t s, r;
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &s, 1);
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseRawAndUncalibrated, 0, &r, 1);
const auto &S = s.mDeviceToAbsoluteTracking, &R = r.mDeviceToAbsoluteTracking;
auto toRaw = [&](Vec3 v) { return Rotate(R, RotateInverse(S, v)); };
const Vec3 o = Position(R) + toRaw(originStanding - Position(S));
double q[4];
BasisQuat({toRaw(b.x), toRaw(b.y), toRaw(b.z)}, q);
char msg[200];
std::snprintf(msg, sizeof msg, "posq %.5f %.5f %.5f %.6f %.6f %.6f %.6f", o.x, o.y, o.z, q[0], q[1], q[2], q[3]);
SendTo(out, "ft_pointer", msg);
}
std::pair<bool, Vec3> Pointer::HeadPos() {
vr::TrackedDevicePose_t s;
sys->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &s, 1);
return std::make_pair(s.bPoseIsValid, Position(s.mDeviceToAbsoluteTracking));
}
// Borrow the device and the laser for a placement: connect, claim, laser mode on.
void Pointer::Borrow() {
SendTo(out, "ft_pointer", "show");
overlay->ShowOverlay(laserMode);
overlay->HideOverlay(cursor);
overlay->HideOverlay(marker);
SleepMs(active ? 50 : 400); // a fresh connect needs SteamVR to bind the device
SendTo(out, "ft_pointer", "btn a 1");
SleepMs(60);
SendTo(out, "ft_pointer", "btn a 0");
}
void Pointer::GiveBack() {
if (!active) {
SendTo(out, "ft_pointer", "hide");
overlay->HideOverlay(laserMode);
laserModeShown = false;
}
}
std::string Pointer::FindPanel(const char *key, Panel &p, Vec3 &eye) {
vr::VROverlayHandle_t h;
if (overlay->FindOverlay(key, &h) != vr::VROverlayError_None) return std::string("no overlay ") + key;
bool valid;
std::tie(valid, eye) = HeadPos();
if (!valid) return "no head pose (headset off?)";
p = ScanPanel(h, eye, 0.5);
if (!p.found) return std::string("panel not visible: ") + key;
return "";
}
// grabprobe (maintenance): aim down from the panel's bottom edge, 1 cm a step, and log where
// SteamVR's laser hits something (the hit dot shows) and whether the window controls are up.
void Pointer::GrabProbe(const char *key) {
Panel p;
Vec3 eye;
const std::string err = FindPanel(key, p, eye);
if (!err.empty()) {
std::printf("grabprobe: %s\n", err.c_str());
std::fflush(stdout);
return;
}
Borrow();
vr::VROverlayHandle_t footer = vr::k_ulOverlayHandleInvalid;
overlay->FindOverlay("valve.steam.gamepadui.floatingfooter", &footer);
const Vec3 bottom = p.center - p.basis.y * (p.height / 2);
std::printf("grabprobe %s: center (%.3f %.3f %.3f) %.3f x %.3f m\n", key, p.center.x, p.center.y, p.center.z,
p.width, p.height);
for (int cm = -5; cm <= 45; ++cm) {
const Vec3 target = bottom - p.basis.y * (cm / 100.0);
SendPose(eye, AimBasis(target - eye));
SleepMs(90);
const bool dot = systemPointer != vr::k_ulOverlayHandleInvalid && overlay->IsOverlayVisible(systemPointer);
const bool foot = footer != vr::k_ulOverlayHandleInvalid && overlay->IsOverlayVisible(footer);
std::printf(" %+3d cm below the bottom edge: steamvr_dot=%d footer=%d", cm, dot, foot);
if (foot) {
vr::ETrackingUniverseOrigin uo;
vr::HmdMatrix34_t t{};
if (overlay->GetOverlayTransformAbsolute(footer, &uo, &t) == vr::VROverlayError_None) {
const Vec3 f = Position(t) - p.center;
std::printf(" footer at panel (%.3f %.3f %.3f)", Dot(f, p.basis.x), Dot(f, p.basis.y),
Dot(f, p.basis.z));
}
}
std::printf("\n");
}
std::fflush(stdout);
GiveBack();
}
// Carry a floating panel so its centre lands on `target` with frame `bt` (see
// "Placement" at the top). Returns "ok ..." or "error ...".
std::string Pointer::Place(const char *key, Vec3 target, const Basis &bt, double grabBelow) {
Panel p;
Vec3 eye;
std::string err = FindPanel(key, p, eye);
if (!err.empty()) return "error " + err;
auto offBy = [&](const Panel &q, double &cm, double &deg) {
cm = Length(q.center - target) * 100;
const double c = (Dot(q.basis.x, bt.x) + Dot(q.basis.y, bt.y) + Dot(q.basis.z, bt.z) - 1) / 2;
deg = std::acos(std::clamp(c, -1.0, 1.0)) * 180 / M_PI;
};
double cm, deg;
int moves = 0;
Borrow();
for (int attempt = 0; attempt < 3; ++attempt) {
offBy(p, cm, deg);
if (cm < 1.5 && deg < 1.0) break;
// The rigid motion that takes the panel to the target: rotate by R, then move.
auto turn = [&](Vec3 v) { return FromBasis(bt, ToBasis(p.basis, v)); };
double q[4];
BasisQuat({turn({1, 0, 0}), turn({0, 1, 0}), turn({0, 0, 1})}, q);
const double angle = 2 * std::acos(std::clamp(q[0], -1.0, 1.0));
const Vec3 axis = std::sin(angle / 2) > 1e-6 ? Normalize({q[1], q[2], q[3]}) : Vec3{0, 1, 0};
const Vec3 grab = p.center - p.basis.y * (p.height / 2 + grabBelow);
const Basis d0 = AimBasis(grab - eye);
const Vec3 o1 = target + turn(eye - p.center); // device origin at the end
++moves;
SendPose(eye, d0);
SleepMs(150); // hover: the window controls come up
SendTo(out, "ft_pointer", "btn trigger 1");
SleepMs(150);
// 1. Rotate about the device origin (the eye): the dashboard applies it exactly.
const int rsteps = std::max(4, int(angle * 180 / M_PI / kPlaceDegPerSec * 60));
for (int i = 1; i <= rsteps; ++i) {
const double a = angle * i / rsteps;
auto r = [&](Vec3 v) { return RotateAbout(v, axis, a); };
SendPose(eye, {r(d0.x), r(d0.y), r(d0.z)});
SleepMs(16);
}
// 2. Slide the device slowly: fast moves are accelerated by the dashboard.
const Basis d1{turn(d0.x), turn(d0.y), turn(d0.z)};
const int tsteps = std::max(4, int(Length(o1 - eye) / cfg.slideSpeed * 60));
for (int i = 1; i <= tsteps; ++i) {
SendPose(eye + (o1 - eye) * (double(i) / tsteps), d1);
SleepMs(16);
}
SleepMs(100);
SendTo(out, "ft_pointer", "btn trigger 0");
SleepMs(600); // the dashboard re-reads the pose up to 150 ms after the release
err = FindPanel(key, p, eye);
if (!err.empty()) break;
}
GiveBack();
if (!err.empty()) return "error after the move: " + err;
offBy(p, cm, deg);
char msg[160];
std::snprintf(msg, sizeof msg, "ok %s off by %.1f cm, %.1f deg after %d move%s", key, cm, deg, moves,
moves == 1 ? "" : "s");
std::printf("place: %s\n", msg);
std::fflush(stdout);
return msg;
}
+23 -123
View File
@@ -279,9 +279,6 @@ PFNEGLCREATEIMAGEKHRPROC pCreateImage;
PFNEGLDESTROYIMAGEKHRPROC pDestroyImage; PFNEGLDESTROYIMAGEKHRPROC pDestroyImage;
PFNGLEGLIMAGETARGETTEXTURE2DOESPROC pImageTargetTexture; PFNGLEGLIMAGETARGETTEXTURE2DOESPROC pImageTargetTexture;
PFNGLEGLIMAGETARGETRENDERBUFFERSTORAGEOESPROC pImageTargetRenderbuffer; PFNGLEGLIMAGETARGETRENDERBUFFERSTORAGEOESPROC pImageTargetRenderbuffer;
PFNEGLCREATESYNCKHRPROC pCreateSync;
PFNEGLDESTROYSYNCKHRPROC pDestroySync;
PFNEGLCLIENTWAITSYNCKHRPROC pClientWaitSync;
const char *kVertex = R"( const char *kVertex = R"(
attribute vec2 pos; // the unit square attribute vec2 pos; // the unit square
@@ -403,11 +400,7 @@ bool Renderer::Init(const std::vector<uint64_t> &modifiers, std::function<void(c
pImageTargetTexture = reinterpret_cast<PFNGLEGLIMAGETARGETTEXTURE2DOESPROC>(eglGetProcAddress("glEGLImageTargetTexture2DOES")); pImageTargetTexture = reinterpret_cast<PFNGLEGLIMAGETARGETTEXTURE2DOESPROC>(eglGetProcAddress("glEGLImageTargetTexture2DOES"));
pImageTargetRenderbuffer = reinterpret_cast<PFNGLEGLIMAGETARGETRENDERBUFFERSTORAGEOESPROC>( pImageTargetRenderbuffer = reinterpret_cast<PFNGLEGLIMAGETARGETRENDERBUFFERSTORAGEOESPROC>(
eglGetProcAddress("glEGLImageTargetRenderbufferStorageOES")); eglGetProcAddress("glEGLImageTargetRenderbufferStorageOES"));
pCreateSync = reinterpret_cast<PFNEGLCREATESYNCKHRPROC>(eglGetProcAddress("eglCreateSyncKHR")); if (!gbm_ || !pGetPlatformDisplay || !pCreateImage || !pImageTargetTexture || !pImageTargetRenderbuffer) {
pDestroySync = reinterpret_cast<PFNEGLDESTROYSYNCKHRPROC>(eglGetProcAddress("eglDestroySyncKHR"));
pClientWaitSync = reinterpret_cast<PFNEGLCLIENTWAITSYNCKHRPROC>(eglGetProcAddress("eglClientWaitSyncKHR"));
if (!gbm_ || !pGetPlatformDisplay || !pCreateImage || !pImageTargetTexture || !pImageTargetRenderbuffer ||
!pCreateSync || !pDestroySync || !pClientWaitSync) {
std::fprintf(stderr, "handcut: GBM or EGL extensions missing\n"); std::fprintf(stderr, "handcut: GBM or EGL extensions missing\n");
return false; return false;
} }
@@ -458,9 +451,6 @@ void Renderer::Forget(const void *key) {
glDeleteTextures(1, &it->second.tex); glDeleteTextures(1, &it->second.tex);
pDestroyImage(EGLDisplay(dpy_), EGLImageKHR(it->second.image)); pDestroyImage(EGLDisplay(dpy_), EGLImageKHR(it->second.image));
imported_.erase(it); imported_.erase(it);
for (auto &[k, r] : rings_) // a new buffer at the same address isn't this one
for (Output &o : r.out)
if (o.key == key) o.drawn = false;
} }
bool Renderer::MakeOutput(Output &o, int w, int h) { bool Renderer::MakeOutput(Output &o, int w, int h) {
@@ -499,7 +489,6 @@ bool Renderer::MakeOutput(Output &o, int w, int h) {
void Renderer::FreeOutput(Output &o) { void Renderer::FreeOutput(Output &o) {
if (o.bo && released_) released_(&o); if (o.bo && released_) released_(&o);
if (o.fence) pDestroySync(EGLDisplay(dpy_), EGLSyncKHR(o.fence));
if (o.fbo) glDeleteFramebuffers(1, &o.fbo); if (o.fbo) glDeleteFramebuffers(1, &o.fbo);
if (o.rb) glDeleteRenderbuffers(1, &o.rb); if (o.rb) glDeleteRenderbuffers(1, &o.rb);
if (o.image) pDestroyImage(EGLDisplay(dpy_), EGLImageKHR(o.image)); if (o.image) pDestroyImage(EGLDisplay(dpy_), EGLImageKHR(o.image));
@@ -516,68 +505,26 @@ void Renderer::DropPanel(int panel) {
rings_.erase(it); rings_.erase(it);
} }
namespace { const Output *Renderer::Composite(int panel, const void *key, const ft_dmabuf &src, const std::vector<Capsule2D> eyes[2]) {
if (!ready_) return nullptr;
// The pixels a cutout's quad covers (see Draw), as x0 y0 x1 y1 in the eye's half. const auto t0 = std::chrono::steady_clock::now();
void Bounds(const Capsule2D &c, float b[4]) { const int w = src.width, h = src.height;
const float feather = std::max(1.5f, 0.15f * std::min(c.ra, c.rb)); Ring &ring = rings_[panel];
const float r = std::max(c.ra, c.rb) + feather; if (ring.w != w || ring.h != h) {
b[0] = std::min(c.ax, c.bx) - r, b[1] = std::min(c.ay, c.by) - r; for (Output &old : ring.out) FreeOutput(old);
b[2] = std::max(c.ax, c.bx) + r, b[3] = std::max(c.ay, c.by) + r; ring.w = w, ring.h = h, ring.next = 0;
} }
Output &o = ring.out[ring.next];
if (!o.bo && !MakeOutput(o, 2 * w, h)) return nullptr;
const GLuint tex = Texture(key, src);
if (!tex) return nullptr;
ring.next = (ring.next + 1) % 3;
// Within a quarter pixel: the same picture.
bool SameSpots(const std::vector<Capsule2D> a[2], const std::vector<Capsule2D> b[2]) {
for (int e = 0; e < 2; ++e) {
if (a[e].size() != b[e].size()) return false;
for (size_t i = 0; i < a[e].size(); ++i) {
const Capsule2D &p = a[e][i], &q = b[e][i];
for (float d : {p.ax - q.ax, p.ay - q.ay, p.bx - q.bx, p.by - q.by, p.ra - q.ra, p.rb - q.rb})
if (std::fabs(d) > 0.25f) return false;
}
}
return true;
}
int64_t SteadyNs() {
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
} // namespace
bool Renderer::Passed(Output &o, int64_t timeoutNs) {
if (!o.fence) return true;
const EGLint r = pClientWaitSync(EGLDisplay(dpy_), EGLSyncKHR(o.fence), 0, EGLTimeKHR(timeoutNs));
if (r == EGL_TIMEOUT_EXPIRED_KHR) return false;
pDestroySync(EGLDisplay(dpy_), EGLSyncKHR(o.fence)); // passed, or failed: don't wait on it again
o.fence = nullptr;
return true;
}
// Draws one buffer. Partial: the buffer holds this client frame already, with o.spots cut
// out, so each eye is drawn again only inside the box around those and the new cutouts.
void Renderer::Draw(Output &o, unsigned tex, int w, int h, const std::vector<Capsule2D> eyes[2], bool partial) {
glBindFramebuffer(GL_FRAMEBUFFER, o.fbo); glBindFramebuffer(GL_FRAMEBUFFER, o.fbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo_); glBindBuffer(GL_ARRAY_BUFFER, vbo_);
glEnableVertexAttribArray(0); glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, nullptr); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, nullptr);
for (int e = 0; e < 2; ++e) { for (int e = 0; e < 2; ++e) {
if (partial) {
float box[4] = {1e9f, 1e9f, -1e9f, -1e9f}, b[4];
const std::vector<Capsule2D> *lists[2] = {&o.spots[e], &eyes[e]};
for (const std::vector<Capsule2D> *list : lists)
for (const Capsule2D &c : *list) {
Bounds(c, b);
box[0] = std::min(box[0], b[0]), box[1] = std::min(box[1], b[1]);
box[2] = std::max(box[2], b[2]), box[3] = std::max(box[3], b[3]);
}
// Window y is the buffer's row, the same way down as the cutouts' y (see kVertex).
const int x0 = std::clamp(int(std::floor(box[0])) - 1, 0, w), y0 = std::clamp(int(std::floor(box[1])) - 1, 0, h);
const int x1 = std::clamp(int(std::ceil(box[2])) + 1, 0, w), y1 = std::clamp(int(std::ceil(box[3])) + 1, 0, h);
if (x1 <= x0 || y1 <= y0) continue; // no cutout in this eye, then or now
glEnable(GL_SCISSOR_TEST);
glScissor(e * w + x0, y0, x1 - x0, y1 - y0);
}
glViewport(e * w, 0, w, h); glViewport(e * w, 0, w, h);
glDisable(GL_BLEND); glDisable(GL_BLEND);
glUseProgram(copyProg_); glUseProgram(copyProg_);
@@ -596,69 +543,22 @@ void Renderer::Draw(Output &o, unsigned tex, int w, int h, const std::vector<Cap
uB = glGetUniformLocation(cutProg_, "b"), uR = glGetUniformLocation(cutProg_, "r"), uB = glGetUniformLocation(cutProg_, "b"), uR = glGetUniformLocation(cutProg_, "r"),
uF = glGetUniformLocation(cutProg_, "feather"); uF = glGetUniformLocation(cutProg_, "feather");
for (const Capsule2D &c : eyes[e]) { for (const Capsule2D &c : eyes[e]) {
float b[4]; const float feather = std::max(1.5f, 0.15f * std::min(c.ra, c.rb));
Bounds(c, b); const float r = std::max(c.ra, c.rb) + feather;
glUniform4f(uRect, b[0], b[1], b[2], b[3]); glUniform4f(uRect, std::min(c.ax, c.bx) - r, std::min(c.ay, c.by) - r, std::max(c.ax, c.bx) + r,
std::max(c.ay, c.by) + r);
glUniform2f(uA, c.ax, c.ay); glUniform2f(uA, c.ax, c.ay);
glUniform2f(uB, c.bx, c.by); glUniform2f(uB, c.bx, c.by);
glUniform2f(uR, c.ra, c.rb); glUniform2f(uR, c.ra, c.rb);
glUniform1f(uF, std::max(1.5f, 0.15f * std::min(c.ra, c.rb))); glUniform1f(uF, feather);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
} }
glDisable(GL_SCISSOR_TEST);
} }
glDisable(GL_BLEND); glDisable(GL_BLEND);
} // SteamVR reads the buffer from another process and GPU queue; make sure it's done.
glFinish();
const Output *Renderer::Composite(int panel, const void *key, uint64_t serial, const ft_dmabuf &src, lastMs_ = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
const std::vector<Capsule2D> eyes[2]) { return &o;
if (!ready_) return nullptr;
const int64_t t0 = SteadyNs();
const int w = src.width, h = src.height;
Ring &ring = rings_[panel];
if (ring.w != w || ring.h != h) {
for (Output &old : ring.out) FreeOutput(old);
ring.w = w, ring.h = h, ring.shown = ring.before = ring.drawing = -1;
}
// After a pause the panel showed its client buffer, so nothing of ours is on it.
if (t0 - ring.lastCall > 30'000'000) ring.shown = ring.before = -1;
ring.lastCall = t0;
auto promote = [&ring] {
ring.before = ring.shown, ring.shown = ring.drawing, ring.drawing = -1;
};
if (ring.drawing >= 0 && Passed(ring.out[ring.drawing], 0)) promote();
const int newest = ring.drawing >= 0 ? ring.drawing : ring.shown;
const Output *n = newest >= 0 ? &ring.out[newest] : nullptr;
if (n && n->key == key && n->serial == serial && SameSpots(n->spots, eyes)) {
++stats_.same;
} else if (ring.drawing >= 0) {
++stats_.busy; // drawn on a later tick, from what's current then
} else {
int i = 0;
while (i == ring.shown || i == ring.before) ++i;
Output &o = ring.out[i];
if (!o.bo && !MakeOutput(o, 2 * w, h)) return nullptr;
const GLuint tex = Texture(key, src);
if (!tex) return nullptr;
const bool partial = o.drawn && o.key == key && o.serial == serial;
Draw(o, tex, w, h, eyes, partial);
o.fence = pCreateSync(EGLDisplay(dpy_), EGL_SYNC_FENCE_KHR, nullptr);
glFlush();
if (!o.fence) glFinish(); // no fence: wait here, as before
o.key = key, o.serial = serial, o.drawn = true;
for (int e = 0; e < 2; ++e) o.spots[e] = eyes[e];
ring.drawing = i;
++stats_.draws, stats_.partial += partial;
}
// Nothing of ours to show yet: wait for this one rather than show none.
if (ring.shown < 0 && ring.drawing >= 0) {
++stats_.waits;
if (Passed(ring.out[ring.drawing], 50'000'000)) promote();
}
lastMs_ = (SteadyNs() - t0) / 1e6;
stats_.cpuMs += lastMs_, stats_.worstMs = std::max(stats_.worstMs, lastMs_);
return ring.shown >= 0 ? &ring.out[ring.shown] : nullptr;
} }
} // namespace handcut } // namespace handcut
+6 -42
View File
@@ -70,7 +70,7 @@ private:
std::map<uint32_t, Motion> motion_; std::map<uint32_t, Motion> motion_;
std::vector<Capsule> world_; // base_, moved ahead std::vector<Capsule> world_; // base_, moved ahead
bool predict_ = true; bool predict_ = true;
int64_t leadNs_ = 36'000'000; // 25 ms to the displays, plus the tick a cutout buffer waits for its fence int64_t leadNs_ = 25'000'000;
int fd_ = -1; int fd_ = -1;
const void *map_ = nullptr; const void *map_ = nullptr;
uint64_t seq_ = 0; uint64_t seq_ = 0;
@@ -91,22 +91,6 @@ struct Output {
void *bo = nullptr; void *bo = nullptr;
unsigned fbo = 0, rb = 0; unsigned fbo = 0, rb = 0;
void *image = nullptr; void *image = nullptr;
// What's drawn in it: the client buffer and its frame, and the cutouts (a later draw
// with the same frame only redraws around the old and new cutouts).
void *fence = nullptr; // the GPU is still drawing it
const void *key = nullptr;
uint64_t serial = 0;
bool drawn = false;
std::vector<Capsule2D> spots[2];
};
// Composite's counts since the last TakeStats.
struct CutStats {
int draws = 0, partial = 0; // buffers drawn, of them only around the cutouts
int same = 0; // nothing changed: the newest buffer stays
int busy = 0; // the GPU hadn't finished the last one: drawn next tick
int waits = 0; // a panel's first buffer, waited for
double cpuMs = 0, worstMs = 0;
}; };
class Renderer { class Renderer {
@@ -115,34 +99,20 @@ public:
// modifiers: what SteamVR takes for DRM_FORMAT_ABGR8888, the outputs' format. // modifiers: what SteamVR takes for DRM_FORMAT_ABGR8888, the outputs' format.
// released: an output is about to be freed (drop its SteamVR import). // released: an output is about to be freed (drop its SteamVR import).
bool Init(const std::vector<uint64_t> &modifiers, std::function<void(const Output *)> released); bool Init(const std::vector<uint64_t> &modifiers, std::function<void(const Output *)> released);
// Draw client buffer `src` (identified by `key`; `serial` counts its frames) into a // Draw client buffer `src` (identified by `key`) into the next output buffer of
// buffer of panel `panel`, both eyes, cutting out `eyes`. Returns the newest buffer the // panel `panel`, both eyes, cutting out `eyes`. Returns that buffer, or null.
// GPU has finished, or null. const Output *Composite(int panel, const void *key, const ft_dmabuf &src, const std::vector<Capsule2D> eyes[2]);
//
// It doesn't wait for the GPU: a buffer is drawn, fenced, and returned from a later call
// once the fence has passed, so what SteamVR shows is a tick behind. Each panel has three
// buffers: the one shown, the one shown before it (SteamVR may still be reading it), and
// the one being drawn. Nothing is drawn when the frame and the cutouts are what the newest
// buffer has, and when only the cutouts moved, a buffer that holds the same client frame
// is drawn again only around them. The first call after a pause (no call for 30 ms, about
// 3 ticks: the panel showed its client buffer meanwhile) waits for its buffer, so a stale
// one never shows.
const Output *Composite(int panel, const void *key, uint64_t serial, const ft_dmabuf &src,
const std::vector<Capsule2D> eyes[2]);
// A client buffer is going away. // A client buffer is going away.
void Forget(const void *key); void Forget(const void *key);
// A panel is gone: drop its outputs. // A panel is gone: drop its outputs.
void DropPanel(int panel); void DropPanel(int panel);
// How long the last Composite took on the CPU, ms. // How long the last Composite took, ms (it waits for the GPU).
double lastMs() const { return lastMs_; } double lastMs() const { return lastMs_; }
CutStats TakeStats() { CutStats s = stats_; stats_ = {}; return s; }
private: private:
unsigned Texture(const void *key, const ft_dmabuf &src); unsigned Texture(const void *key, const ft_dmabuf &src);
bool MakeOutput(Output &o, int w, int h); bool MakeOutput(Output &o, int w, int h);
void FreeOutput(Output &o); void FreeOutput(Output &o);
bool Passed(Output &o, int64_t timeoutNs);
void Draw(Output &o, unsigned tex, int w, int h, const std::vector<Capsule2D> eyes[2], bool partial);
bool ready_ = false; bool ready_ = false;
int drm_ = -1; int drm_ = -1;
void *gbm_ = nullptr, *dpy_ = nullptr, *ctx_ = nullptr; void *gbm_ = nullptr, *dpy_ = nullptr, *ctx_ = nullptr;
@@ -151,15 +121,9 @@ private:
std::function<void(const Output *)> released_; std::function<void(const Output *)> released_;
struct Imported { void *image; unsigned tex; }; struct Imported { void *image; unsigned tex; };
std::map<const void *, Imported> imported_; std::map<const void *, Imported> imported_;
struct Ring { struct Ring { Output out[3]; int next = 0; int w = 0, h = 0; };
Output out[3];
int shown = -1, before = -1, drawing = -1; // indices into out
int w = 0, h = 0;
int64_t lastCall = 0; // steady clock ns
};
std::map<int, Ring> rings_; std::map<int, Ring> rings_;
double lastMs_ = 0; double lastMs_ = 0;
CutStats stats_;
}; };
} // namespace handcut } // namespace handcut
+5 -6
View File
@@ -163,7 +163,7 @@ int main(int argc, char **argv) {
handcut::EyePositions(head, eyes); handcut::EyePositions(head, eyes);
cut = handcut::Project(panel, hands.capsules(), eyes, eyes2d); cut = handcut::Project(panel, hands.capsules(), eyes, eyes2d);
} }
const handcut::Output *out = cut ? renderer.Composite(0, bo, 1, client, eyes2d) : nullptr; const handcut::Output *out = cut ? renderer.Composite(0, bo, client, eyes2d) : nullptr;
if (out) { if (out) {
auto it = imports.find(out); auto it = imports.find(out);
if (it == imports.end()) { if (it == imports.end()) {
@@ -177,7 +177,8 @@ int main(int argc, char **argv) {
vr::VROverlay()->SetOverlayFlag(ov, vr::VROverlayFlags_SideBySide_Parallel, true); vr::VROverlay()->SetOverlayFlag(ov, vr::VROverlayFlags_SideBySide_Parallel, true);
cutting = true; cutting = true;
} }
if (shown != it->second) shown = it->second, vr::VROverlay()->SetOverlayTexture(ov, &tex); shown = it->second;
vr::VROverlay()->SetOverlayTexture(ov, &tex);
ms += renderer.lastMs(), worst = std::max(worst, renderer.lastMs()); ms += renderer.lastMs(), worst = std::max(worst, renderer.lastMs());
++cutFrames; ++cutFrames;
caps2d += eyes2d[0].size() + eyes2d[1].size(); caps2d += eyes2d[0].size() + eyes2d[1].size();
@@ -191,12 +192,10 @@ int main(int argc, char **argv) {
} }
++frames; ++frames;
if (now - lastReport > 2'000'000'000) { if (now - lastReport > 2'000'000'000) {
const handcut::CutStats st = renderer.TakeStats();
std::printf("%.0f s: %d ticks, %d with a cutout (%.1f capsules per eye), composite %.2f ms avg %.2f ms worst, " std::printf("%.0f s: %d ticks, %d with a cutout (%.1f capsules per eye), composite %.2f ms avg %.2f ms worst, "
"%zu hand capsules known; %d draws (%d partial), %d same, %d busy\n", "%zu hand capsules known\n",
(now - start) / 1e9, frames, cutFrames, cutFrames ? caps2d / 2.0 / cutFrames : 0.0, (now - start) / 1e9, frames, cutFrames, cutFrames ? caps2d / 2.0 / cutFrames : 0.0,
cutFrames ? ms / cutFrames : 0.0, worst, hands.capsules().size(), st.draws, st.partial, st.same, cutFrames ? ms / cutFrames : 0.0, worst, hands.capsules().size());
st.busy);
std::fflush(stdout); std::fflush(stdout);
lastReport = now, frames = cutFrames = 0, ms = worst = 0, caps2d = 0; lastReport = now, frames = cutFrames = 0, ms = worst = 0, caps2d = 0;
} }
+7 -18
View File
@@ -270,9 +270,7 @@ struct Screen {
const void *key = nullptr; // the client buffer on it now, and its dmabuf (for cutouts) const void *key = nullptr; // the client buffer on it now, and its dmabuf (for cutouts)
ft_dmabuf buf{}; ft_dmabuf buf{};
vr::SharedTextureHandle_t plain = 0; // that buffer's SteamVR import vr::SharedTextureHandle_t plain = 0; // that buffer's SteamVR import
uint64_t frames = 0; // client frames presented (a cutout buffer's "serial")
bool cutting = false; // showing a cutout buffer (side by side) instead bool cutting = false; // showing a cutout buffer (side by side) instead
vr::SharedTextureHandle_t cutShown = 0; // ...this one
double chrome = 0.3; // the bar's width; the other controls follow it (ChromeSize) double chrome = 0.3; // the bar's width; the other controls follow it (ChromeSize)
double grip = 0.04; // the corner tab's and the round buttons' size double grip = 0.04; // the corner tab's and the round buttons' size
// A floating window's panel (see the top): the window's rectangle in the buffer, its // A floating window's panel (see the top): the window's rectangle in the buffer, its
@@ -1440,7 +1438,7 @@ void StopCutting(Screen &s) {
vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_SideBySide_Parallel, false); vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_SideBySide_Parallel, false);
vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_IgnoreTextureAlpha, true); vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_IgnoreTextureAlpha, true);
if (s.plain) SetScreenTexture(s, s.plain); if (s.plain) SetScreenTexture(s, s.plain);
s.cutting = false, s.cutShown = 0; s.cutting = false;
} }
// Each tick: for each visible screen with a hand in front of it (for either eye), draw its // Each tick: for each visible screen with a hand in front of it (for either eye), draw its
@@ -1461,7 +1459,7 @@ void UpdateCutouts() {
bool cut = hands && s.visible && !s.floating && s.key && s.width > 0 && ScreenPose(s, &p) && bool cut = hands && s.visible && !s.floating && s.key && s.width > 0 && ScreenPose(s, &p) &&
handcut::Project({p, s.metres, s.heightMetres(), s.curve, s.width, s.height}, g_hands.capsules(), handcut::Project({p, s.metres, s.heightMetres(), s.curve, s.width, s.height}, g_hands.capsules(),
eyes, spots); eyes, spots);
const handcut::Output *out = cut && CutterReady() ? g_cutter.Composite(i, s.key, s.frames, s.buf, spots) : nullptr; const handcut::Output *out = cut && CutterReady() ? g_cutter.Composite(i, s.key, s.buf, spots) : nullptr;
const vr::SharedTextureHandle_t h = out ? ImportCutout(out) : 0; const vr::SharedTextureHandle_t h = out ? ImportCutout(out) : 0;
if (!h) { if (!h) {
StopCutting(s); StopCutting(s);
@@ -1472,7 +1470,7 @@ void UpdateCutouts() {
vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_SideBySide_Parallel, true); vr::VROverlay()->SetOverlayFlag(s.overlay, vr::VROverlayFlags_SideBySide_Parallel, true);
s.cutting = true; s.cutting = true;
} }
if (h != s.cutShown) SetScreenTexture(s, h), s.cutShown = h; // the same buffer stays: no new frame for SteamVR SetScreenTexture(s, h);
} }
} }
@@ -1701,7 +1699,7 @@ bool ft_vr_screen_present(int index, const void *key, const struct ft_dmabuf *b)
PlaceChrome(s); // the height changed PlaceChrome(s); // the height changed
std::printf("screen %d: %dx%d\n", index + 1, s.width, s.height); std::printf("screen %d: %dx%d\n", index + 1, s.width, s.height);
} }
s.key = key, s.buf = *b, s.plain = it->second, ++s.frames; s.key = key, s.buf = *b, s.plain = it->second;
// While cutting, the next tick draws the new buffer with the cutouts (never floating). // While cutting, the next tick draws the new buffer with the cutouts (never floating).
if (!s.cutting) SetScreenTexture(s, it->second); if (!s.cutting) SetScreenTexture(s, it->second);
vr::SharedTextureHandle_t handle = it->second; vr::SharedTextureHandle_t handle = it->second;
@@ -2133,18 +2131,9 @@ void ft_vr_command(const char *cmd, char *reply, int size) {
g_hands.SetPrediction(g_hands.predicting(), ms); g_hands.SetPrediction(g_hands.predicting(), ms);
else if (std::strcmp(word, "state") != 0) else if (std::strcmp(word, "state") != 0)
return (void)std::snprintf(reply, size, "error cutouts on|off|state|predict on|off|lead <ms>"); return (void)std::snprintf(reply, size, "error cutouts on|off|state|predict on|off|lead <ms>");
// Composite's counts since the last state, per second. std::snprintf(reply, size, "ok %s %s %.2f ms, predict %s lead %.0f ms", g_cutouts ? "on" : "off",
static auto since = Clock::now(); g_cutterState > 0 ? "ready" : g_cutterState < 0 ? "unavailable" : "idle", g_cutter.lastMs(),
const double dt = std::max(1e-3, std::chrono::duration<double>(Clock::now() - since).count()); g_hands.predicting() ? "on" : "off", g_hands.leadMs());
since = Clock::now();
const handcut::CutStats c = g_cutter.TakeStats();
const int calls = c.draws + c.same + c.busy;
std::snprintf(reply, size,
"ok %s %s %.2f ms, predict %s lead %.0f ms; per s: %.1f draws (%.1f partial), %.1f same, %.1f busy, "
"%.1f waits; CPU %.2f ms avg %.2f worst",
g_cutouts ? "on" : "off", g_cutterState > 0 ? "ready" : g_cutterState < 0 ? "unavailable" : "idle",
g_cutter.lastMs(), g_hands.predicting() ? "on" : "off", g_hands.leadMs(), c.draws / dt,
c.partial / dt, c.same / dt, c.busy / dt, c.waits / dt, calls ? c.cpuMs / calls : 0.0, c.worstMs);
} else if (int x0, y0, w0, h0, t0; std::sscanf(cmd, "float %d %lf %d %d %d %d %d", &n, &w, &x0, &y0, &w0, &h0, &t0) == 7) { } else if (int x0, y0, w0, h0, t0; std::sscanf(cmd, "float %d %lf %d %d %d %d %d", &n, &w, &x0, &y0, &w0, &h0, &t0) == 7) {
Screen *s = Find(n); Screen *s = Find(n);
if (!s || !s->floating) return (void)std::snprintf(reply, size, "error no floating window panel %d", n); if (!s || !s->floating) return (void)std::snprintf(reply, size, "error no floating window panel %d", n);
+2 -2
View File
@@ -285,11 +285,11 @@ def check_openvr():
def check_overlays(desktop_up): def check_overlays(desktop_up):
code, out = run(f"{STEAMVR_BIN}/vrcmd", "--overlays", code, out = run(f"{STEAMVR_BIN}/vrcmd", "--overlays",
env=dict(os.environ, LD_LIBRARY_PATH=STEAMVR_BIN)) env=dict(os.environ, LD_LIBRARY_PATH=STEAMVR_BIN))
# The format ft-pointer.cpp and ft_layout.py parse: 'key' -- 'name', WxH visible VROverlayType_... # The format the pointer helper (pointer.h) and ft_layout.py parse: 'key' -- 'name', WxH visible VROverlayType_...
keys = re.findall(r"^'([^']+)' -- '.*VROverlayType_", out, re.M) keys = re.findall(r"^'([^']+)' -- '.*VROverlayType_", out, re.M)
if not keys: if not keys:
report("FAIL", "vrcmd --overlays", "lists no overlays in the expected format, so the 3D mouse can't " report("FAIL", "vrcmd --overlays", "lists no overlays in the expected format, so the 3D mouse can't "
"find panels (pointer/helper/ft-pointer.cpp and layout/ft_layout.py parse it)") "find panels (pointer/helper/pointer.h and layout/ft_layout.py parse it)")
elif desktop_up and not any(re.fullmatch(r"frametop\.screen\.\d+", k) for k in keys): elif desktop_up and not any(re.fullmatch(r"frametop\.screen\.\d+", k) for k in keys):
report("FAIL", "vrcmd --overlays", f"lists {len(keys)} overlays, but none of the desktop's screens") report("FAIL", "vrcmd --overlays", f"lists {len(keys)} overlays, but none of the desktop's screens")
else: else: