Fade wrist panel by viewer-relative angle

This commit is contained in:
baketnk committed 2026-09-24 16:39:16 -04:00
1 parent 33533ba8a2
commit afb8509408
6 files changed
+170 -2

No files matched your search

+4
View File
@@ -123,6 +123,10 @@ if(BUILD_TESTING AND NOT CMAKE_CROSSCOMPILING)
add_executable(frameyap_mount_test tests/mount_test.cpp)
target_link_libraries(frameyap_mount_test PRIVATE frameyap_mount)
add_test(NAME frameyap.mount COMMAND frameyap_mount_test)
add_executable(frameyap_angle_fade_test tests/angle_fade_test.cpp)
target_include_directories(frameyap_angle_fade_test PRIVATE src)
target_compile_options(frameyap_angle_fade_test PRIVATE -UNDEBUG)
add_test(NAME frameyap.angle_fade COMMAND frameyap_angle_fade_test)
add_executable(frameyap_laser_setting_test tests/laser_setting_test.cpp)
target_link_libraries(frameyap_laser_setting_test PRIVATE frameyap_mount)
target_compile_options(frameyap_laser_setting_test PRIVATE -UNDEBUG)
+3
View File
@@ -33,6 +33,9 @@ See [third-party notes](docs/third-party.md). No GitHub release is published yet
lower-right corner bracket to scale it (world, head or wrist). Both sit in
transparent margins, like Steam's window handles. Scaling keeps the upper-left
corner anchored. Position/size changes last for this run only.
Wrist mounting fades the panel as its full orientation turns away from an
upright viewer-facing pose (60°–75°), hiding interaction past that angle;
world and head mounting do not fade.
**Bindings** requests SteamVR's binding editor directly. Existing SteamVR overrides may supersede defaults. World-space by
default; settings offer left wrist, right wrist and head mounting, plus recenter.
Dashboard lasers provide clickable controls. Settings → Lasers anytime is an
+10 -1
View File
@@ -239,7 +239,16 @@ inward with upright, unmirrored text. The controller-relative center is
(0, 0.18, 0.089) m, approximating the compact HUD's surface center: its
0.12 m wrist lift, 0.09 m bottom anchor and ~0.03 m panel-center correction;
Z combines the fallback 0.054 m wrist calibration and 0.035 m finger-back offset. This copies placement geometry, not
VR Workspace's avatar-dependent wrist calibration or its head-facing fade.
VR Workspace's avatar-dependent wrist calibration. Wrist-mounted panels now use
the same *behavior* as kouseki's watch HUD: fully visible while their entire
orientation is within 60° of an upright, viewer-facing panel; linear opacity
fade from 60° to 75°, then hidden (including laser interaction). Pitch, yaw and
roll contribute together; turning the wrist away or moving the head around it
changes the angle. OpenVR's overlay alpha changes without rerendering the panel.
World and head mounts do not fade. Missing headset tracking hides a wrist panel;
a lost wrist still uses the existing world-space fallback. This was implemented
independently with no kouseki library or runtime dependency. Headset readability,
fade feel and interaction at the threshold still need live acceptance.
To tune the selected wrist, set `wrist` in `config.json` as in the example above:
`x`, `y`, `z` are controller-local meters (each -0.3 to 0.3), `width` is panel
+67
View File
@@ -0,0 +1,67 @@
#pragma once
#include "mount.hpp"
#include <algorithm>
#include <cmath>
namespace frameyap {
namespace angle_fade_detail {
struct Vec3 { double x, y, z; };
inline Vec3 axis(const Matrix34& m, int column) { return {m[0][column], m[1][column], m[2][column]}; }
inline Vec3 operator-(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
inline Vec3 operator*(Vec3 a, double s) { return {a.x * s, a.y * s, a.z * s}; }
inline double dot(Vec3 a, Vec3 b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
inline Vec3 cross(Vec3 a, Vec3 b) {
return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x};
}
inline bool normalize(Vec3& v) {
const double n = std::sqrt(dot(v, v));
if (!std::isfinite(n) || n < 1e-5) return false;
v = v * (1. / n);
return true;
}
} // namespace angle_fade_detail
// Wrist-only visibility: compare the entire panel orientation (including roll)
// to an upright panel pointing at the viewer. Kouseki's watch fades linearly
// between 60 and 75 degrees; the matrix math here has no engine dependency.
// Both poses must be in the same tracking space, with rigid orthonormal axes.
// Invalid geometry hides the panel rather than making it clickable at full alpha.
inline float wrist_view_opacity(const Matrix34& panel, const Matrix34& head) {
using namespace angle_fade_detail;
Vec3 front = axis(head, 3) - axis(panel, 3);
if (!normalize(front)) return 0.f;
Vec3 right = axis(head, 0);
right = right - front * dot(right, front);
if (!normalize(right)) {
right = cross(axis(head, 1), front);
if (!normalize(right)) return 0.f;
}
Vec3 up = cross(front, right);
if (!normalize(up)) return 0.f;
Vec3 actual_right = axis(panel, 0), actual_up = axis(panel, 1), actual_front = axis(panel, 2);
if (!normalize(actual_right) || !normalize(actual_up) || !normalize(actual_front)) return 0.f;
// Trace of readable^T * actual is 1 + 2 cos(angle) for rotations.
// Unlike a front-normal dot product, this also rejects upside-down text.
const double cosine = std::clamp((dot(right, actual_right) + dot(up, actual_up) +
dot(front, actual_front) - 1.) * .5, -1., 1.);
if (!std::isfinite(cosine)) return 0.f;
constexpr double pi = 3.14159265358979323846;
const double angle = std::acos(cosine);
return static_cast<float>(1. - std::clamp((angle - pi / 3.) / (pi / 12.), 0., 1.));
}
// OpenVR wrist transforms are controller-relative; tracking poses are absolute.
// Compose into the headset's tracking space before comparing orientations.
inline float wrist_view_opacity_relative(const Matrix34& local_panel, const Matrix34& anchor,
const Matrix34& head) {
Matrix34 world{};
for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) {
world[r][c] = c == 3 ? anchor[r][3] : 0.f;
for (int k = 0; k < 3; ++k) world[r][c] += anchor[r][k] * local_panel[k][c];
}
return wrist_view_opacity(world, head);
}
} // namespace frameyap
+18 -1
View File
@@ -1,5 +1,6 @@
#include "overlay.hpp"
#include "overlay_texture.hpp"
#include "angle_fade.hpp"
#include "gestures.hpp"
#include "laser_setting.hpp"
#include "panel_surface.hpp"
@@ -9,6 +10,7 @@
#include <algorithm>
#include <array>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <filesystem>
#include <iostream>
@@ -89,6 +91,7 @@ struct Overlay::Impl {
Panel panel;
bool save_failed = false, debug_save_failed = false, auto_save_failed = false;
bool world_ready = false, placed = false, has_texture = false, shown = false;
float published_alpha = -1.f;
float size_scale = 1.f;
bool size_changed = false;
float move_x = 0.f, move_y = 0.f;
@@ -208,7 +211,21 @@ struct Overlay::Impl {
gpu_texture.reset();
}
void visibility() {
const bool wanted = placed && has_texture;
float alpha = 1.f;
if (placed && applied_mount && (*applied_mount == Mount::LeftWrist || *applied_mount == Mount::RightWrist)) {
alpha = tracked(anchor) && tracked(vr::k_unTrackedDeviceIndex_Hmd)
? wrist_view_opacity_relative(canvas_pose, matrix(poses[anchor].mDeviceToAbsoluteTracking),
matrix(poses[vr::k_unTrackedDeviceIndex_Hmd].mDeviceToAbsoluteTracking))
: 0.f;
}
// Keep geometry/texture stationary; opacity is a compositor property,
// not an upload. At zero, hide the interactive overlay as well.
alpha = std::round(alpha * 255.f) / 255.f;
if (alpha != published_alpha) {
overlay_check(overlay->SetOverlayAlpha(handle, alpha), overlay, "SetOverlayAlpha");
published_alpha = alpha;
}
const bool wanted = placed && has_texture && alpha > 0.f;
if (wanted == shown) return;
overlay_check(wanted ? overlay->ShowOverlay(handle) : overlay->HideOverlay(handle), overlay, "Overlay visibility");
if (wanted) ++show_calls; else ++hide_calls;
+68
View File
@@ -0,0 +1,68 @@
#include "angle_fade.hpp"
#include <cmath>
#include <cstdlib>
#include <iostream>
#include <limits>
using namespace frameyap;
#define CHECK(x) do { if (!(x)) { std::cerr << "line " << __LINE__ << ": " #x "\n"; std::exit(1); } } while (0)
namespace {
constexpr Matrix34 identity{{{{1.f, 0.f, 0.f, 0.f}},
{{0.f, 1.f, 0.f, 0.f}},
{{0.f, 0.f, 1.f, 0.f}}}};
Matrix34 pitch(float degrees) {
auto pose = identity;
const float a = degrees * 3.14159265358979323846f / 180.f;
pose[1][1] = pose[2][2] = std::cos(a);
pose[1][2] = -std::sin(a); pose[2][1] = std::sin(a);
return pose;
}
Matrix34 roll(float degrees) {
auto pose = identity;
const float a = degrees * 3.14159265358979323846f / 180.f;
pose[0][0] = pose[1][1] = std::cos(a);
pose[0][1] = -std::sin(a); pose[1][0] = std::sin(a);
return pose;
}
bool near(float a, float b) { return std::abs(a - b) < 1e-4f; }
} // namespace
int main() {
auto head = identity;
head[2][3] = 1.f; // facing +Z of the panel
CHECK(near(wrist_view_opacity(identity, head), 1.f));
CHECK(near(wrist_view_opacity(pitch(60.f), head), 1.f));
CHECK(near(wrist_view_opacity(pitch(67.5f), head), .5f));
CHECK(near(wrist_view_opacity(pitch(75.f), head), 0.f));
CHECK(near(wrist_view_opacity(pitch(-67.5f), head), .5f));
CHECK(near(wrist_view_opacity(roll(67.5f), head), .5f)); // no front-only shortcut
CHECK(near(wrist_view_opacity(roll(180.f), head), 0.f));
auto anchor = identity;
anchor[0] = {0.f, 0.f, 1.f, 3.f};
anchor[2] = {-1.f, 0.f, 0.f, 2.f};
auto turned_head = anchor;
turned_head[0][3] = 4.f;
CHECK(near(wrist_view_opacity_relative(identity, anchor, turned_head), 1.f));
CHECK(near(wrist_view_opacity_relative(pitch(67.5f), anchor, turned_head), .5f));
const auto tilted = pitch(55.f);
auto combined = tilted;
// Add a roll about the viewer axis: each axis alone is readable, combined is not.
auto r = roll(55.f);
for (int row = 0; row < 3; ++row) for (int col = 0; col < 3; ++col) {
float value = 0;
for (int k = 0; k < 3; ++k) value += r[row][k] * tilted[k][col];
combined[row][col] = value;
}
CHECK(near(wrist_view_opacity(combined, head), 0.f));
auto behind = head;
behind[2][3] = -1.f;
CHECK(near(wrist_view_opacity(identity, behind), 0.f));
auto offset = head;
offset[0][3] = 2.f; // camera moved aside, not rotated
CHECK(wrist_view_opacity(identity, offset) < 1.f);
CHECK(near(wrist_view_opacity(identity, identity), 0.f));
auto invalid = identity;
invalid[0][0] = std::numeric_limits<float>::quiet_NaN();
CHECK(near(wrist_view_opacity(invalid, head), 0.f));
std::cout << "wrist angle fade checks passed\n";
}