From afb85094083b75317eb01b88244f71aac110282b Mon Sep 17 00:00:00 2001 From: baketnk Date: Thu, 24 Sep 2026 16:39:16 -0400 Subject: [PATCH] Fade wrist panel by viewer-relative angle --- CMakeLists.txt | 4 +++ README.md | 3 ++ docs/overlay.md | 11 ++++++- src/angle_fade.hpp | 67 ++++++++++++++++++++++++++++++++++++++ src/overlay.cpp | 19 ++++++++++- tests/angle_fade_test.cpp | 68 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/angle_fade.hpp create mode 100644 tests/angle_fade_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5d262dc..3ebdd68 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/README.md b/README.md index f0149e2..6ef58ba 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/overlay.md b/docs/overlay.md index faa31ac..8dce85a 100644 --- a/docs/overlay.md +++ b/docs/overlay.md @@ -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 diff --git a/src/angle_fade.hpp b/src/angle_fade.hpp new file mode 100644 index 0000000..03b7a7a --- /dev/null +++ b/src/angle_fade.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "mount.hpp" + +#include +#include + +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(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 diff --git a/src/overlay.cpp b/src/overlay.cpp index 6d187ba..4267043 100644 --- a/src/overlay.cpp +++ b/src/overlay.cpp @@ -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 #include #include +#include #include #include #include @@ -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; diff --git a/tests/angle_fade_test.cpp b/tests/angle_fade_test.cpp new file mode 100644 index 0000000..9e12d84 --- /dev/null +++ b/tests/angle_fade_test.cpp @@ -0,0 +1,68 @@ +#include "angle_fade.hpp" + +#include +#include +#include +#include + +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::quiet_NaN(); + CHECK(near(wrist_view_opacity(invalid, head), 0.f)); + std::cout << "wrist angle fade checks passed\n"; +}